Skip to content

feat(custom-model): generate Run-menu entries from saved endpoint profiles - #430

Merged
Ark0N merged 37 commits into
Ark0N:masterfrom
opticon454:feature/run-menu-custom-model-picker
Sep 19, 2026
Merged

Ark0N merged 37 commits into
Ark0N:masterfrom
opticon454:feature/run-menu-custom-model-picker

Conversation

@opticon454

@opticon454 opticon454 commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #393. Picks up exactly what @Ark0N invited in the merge comment there:

On your Run-menu idea from Saturday: yes, that is where I would put it too. [...] generate those entries from the saved profiles rather than a fixed duplicate per harness, and put it in a follow-up PR so this one stays the backend.

This PR grew substantially past that original scope while live-testing against a real llama-swap server (http://10.10.11.241:8080, an actual Qwen/Gemma/Ministral/Phi fleet on codeman-test-picker, a dedicated throwaway container never touched in production) — every item below marked "confirmed live" was reproduced and fixed against real infrastructure, not assumed from reading the code. This summary reflects the PR's final state; see the commit history for the incremental story.

What this adds

Run menu: a "Custom Endpoints" section generates one entry per (harness that declares capabilities.customModelInjection, saved endpoint) pair, e.g. "Claude Code (llama.cpp)". The harness list comes from window.__codemanCustomModelClis, injected at page render straight off the CLI registry (never a hardcoded id list in the frontend), so a CLI whose injection recipe lands later needs no frontend change. With exactly one discovered model an entry launches straight away; with two or more, a small scrollable dialog asks which one, the endpoint's defaultModelId marked but never auto-chosen.

Settings: App Settings → Models → Custom model endpoints wires up the customModelEndpointsEnabled toggle (declared in #393, read by nothing until now) plus full CRUD against /api/model-endpoints: list, add/edit, delete, discover models. Endpoints also now re-discover themselves automatically every 5 minutes in the background, one unreachable endpoint never blocking the others.

Two launch paths, chosen by mechanism, not preference: opencode, Codex, Gemini, Pi, Grok, DeepSeek and OMP launch one-shotPOST /api/quick-start now accepts a customModel: {endpointId, modelId, confirmed?} field, computing the injection before the session/process exists, so there's no visible native-boot-then-restart (confirmed live on Codex, whose TUI fully reinitializes on a restart). Claude still uses the original restart-in-place design (POST /api/sessions/:id/custom-model) — its --resume-based restart is far less jarring than the other seven's, and runClaude()'s multi-tab + docker-config-drift-retry logic haven't been folded into the one-shot path yet. Both paths run the exact same server-side checks below, never a lighter duplicate. Remote (SSH) and Docker sessions are refused (400) for both — their restart/creation reattaches durable tmux rather than relaunching the agent.

Hardening found by actually running it

  • Session-busy false refusal. A freshly launched CLI reports itself busy for its own startup (spinner, workspace-trust check) well before the apply call would reach it, and the apply route correctly refuses to restart mid-turn — indistinguishable from a fresh boot. The picker now waits for the new session to go idle (bounded 20s, never an error on timeout) before applying.
  • Errors and confirmations you can actually read. Toasts default to sticky with a close button; a failed apply's real server-side reason (not a generic message) reaches the toast. The llama-swap conflict/context-window warnings render as in-app modals, never a browser-native confirm() popup.
  • "Both claude.ai and ANTHROPIC_API_KEY set" warning, eliminated. A custom-model Claude session runs with an isolated CLAUDE_CONFIG_DIR so the injected key never coexists with a stored OAuth login (projects symlinked back so the response viewer/subagent windows/Read My Mind keep working), with that otherwise-empty directory's "Detected a custom API key" trust-dialog pre-approved so it doesn't block every launch with nobody at a TTY to answer.
  • Claude's whole first-run wizard, on every single launch — eliminated too. A fresh, isolated CLAUDE_CONFIG_DIR looks like a brand-new profile to Claude Code, so it replayed the theme picker, the security-notes screen, the per-project trust dialog, and a one-time bypass-permissions warning every time (confirmed live). skipFirstRunPrompts pre-seeds the same "already onboarded" state a real profile accumulates, so a custom-model launch reaches the conversation exactly as fast as a native cloud one.
  • Context-window overflow. Claude Code assumes a large default window for a model id it doesn't recognize and never compacts, silently overflowing a real local model's much smaller context (confirmed live). Discovery now learns each model's real context length from llama-swap's own launch command (GET /running's cmd field — --fit-ctx/-c/--ctx-size), not /props, whose n_ctx was confirmed live to report the theoretical/trained maximum rather than the real runtime size (a measured 154112-vs-16384 discrepancy).
  • A model too small for Claude Code to even start. Even with the fix above applied correctly, Claude Code's own system-prompt/tool-schema overhead (~36.4K tokens, confirmed live twice) can exceed a small model's entire context before any conversation history exists to trim — no context-length declaration fixes that. Both apply routes now warn before launching (requiresContextWarning, gated on the registry declaring contextLengthVar — a no-op for every non-Claude harness), naming the model, its real context, the ~40K safe floor, and the actual fix (an explicit larger -c/--ctx-size in llama-swap instead of relying on --fit-ctx auto-fit).
  • The real root cause of "it still says opus, not my model." llama.cpp runs one model at a time; llama-swap swaps on demand, which can take well over a minute — long enough that a session mid-swap looks identical to one that never left the native backend. Applying now checks GET /running first, refuses (requiresConfirmation) when switching would unload a model another live session is actively using, and fires the smallest real /v1/chat/completions request that actually triggers llama-swap's lazy load (it has no dedicated "switch model" endpoint — confirmed live that applying a selection alone never reached it at all).
  • A session's model getting silently swapped out later, not just at launch. The conflict check above only runs at creation/apply time — confirmed live: a second Codex session picking a different model launched with no warning at all, since nothing conflicted at that exact instant, yet it silently evicted the first session's model anyway. A new 20s background sweep (detectCustomModelSwapDisplacements) compares each live custom-model session's own model against what's actually loaded and broadcasts a toast naming the displaced session — once per displacement, re-arming if it happens again.
  • The loading banner now shows the real backend status, not a guess. llama-swap's GET /api/events SSE stream carries the actual llama-server process's own stdout (load_model: loading model '<path>', llama_server: model loaded, tokenizer warnings), filtered to source: "upstream" frames only. ⚠️ Caught and fixed before merge: the first cut targeted GET /logs (the name that suggests it), shipped with passing tests, and only a live check revealed /logs carries ONLY llama-swap's own proxy request log and never a single backend line — corrected once the real source (/api/events) was found.
  • The countdown and auto-timeout are gone, replaced by a disclaimer and a Cancel button. The size-scaled expected-time estimate and matching auto-close were both a guess dressed up as a fact — real load time depends on hardware this feature has no way to know, and the old timeout could kill a genuinely slow load partway through on slower hardware. The banner now says it can take a while depending on hardware/model size, polls indefinitely, and a Cancel button on the banner itself ends the wait and closes the session on the user's own call.
  • Two UI polish fixes from actually clicking through the dialogs live: the swap-confirm/context-warning modals' z-index sat under the centred status banner (a dialog could render fully hidden behind "Claude started — switching to llama-swap…"); their Cancel/confirm buttons stacked instead of sitting side by side. Both fixed.

Known gaps, documented rather than glossed over

  • Codex: config structure is correct, and a plain chat turn succeeds against a llama-swap deployment that answers /v1/responses — but a real tool-call attempt comes back as inert text rather than an executable function_call (confirmed via codex exec --json's raw event stream), so it remains not usable for actual coding work. Also always prints a harmless Model metadata ... not found warning (sourced from a local cache of OpenAI's own hosted model catalog that a custom model can never appear in — not something to build around).
  • Gemini: fails with Invalid auth method selected, traced to an undocumented GATEWAY auth path — unresolved after real investigation.
  • DeepSeek: originally reached the server but got a consistent HTTP_404; root-caused and fixed. Its own bundled provider module (@deepseek-ai/dsh-llm-deepseek, installed locally purely to read its source) builds the request URL as ${DEEPSEEK_BASE_URL}/chat/completions with no /v1 insertion of its own — llama-swap only serves /v1/chat/completions, and live-testing confirmed .../chat/completions 404s while .../v1/chat/completions succeeds on the same endpoint, with dsh's own error template reproducing the original symptom exactly. Fixed with a new appendV1Suffix registry flag (deepseek's entry only). Not yet re-run end-to-end through a real dsh binary — no install available in this environment — so this is source- and HTTP-level-confirmed rather than a full verified "hello world" reply like the harnesses below.
  • Antigravity: no known custom-endpoint mechanism at all; unsupported.
  • Claude, opencode, Pi, Grok, OMP are verified end-to-end (a real reply came back through the endpoint).

Docs

docs/custom-model-endpoints.md (user guide) and docs/custom-model-endpoints-plan.md (design + per-CLI confidence table) cover everything above in full. docs/wiki/Custom-Model-Endpoints.md (auto-synced to the GitHub wiki) is the equivalent user-facing walkthrough. docs/api-reference.md documents the new running-status route, the requiresConfirmation/requiresContextWarning response shapes, and POST /api/quick-start's customModel field. CLAUDE.md's Custom Model Endpoint Profiles entry is brought current with every mechanism above.

Tests

New/extended: test/custom-model-injection*.test.ts, test/custom-model-endpoint-rediscovery.test.ts, test/custom-model-one-shot-launch.test.ts, test/custom-model-swap-displacement.test.ts, test/custom-model-log-tail.test.ts, test/custom-model-run-menu-ui.test.ts, test/routes/custom-model-routes.test.ts, test/routes/session-custom-model.test.ts, test/routes/quick-start-custom-model.test.ts, plus the pre-existing render-index-html/CLI-registry-branching guards.

npm run typecheck, npm run lint, and node scripts/check-frontend-syntax.mjs are all clean. npm test shows no regressions versus master — every failure on this machine (Windows) is pre-existing environment noise (missing npx/tmux for the TUI e2e suite, EPERM on fs.watch, HEIC tooling, a Windows file-mode-bits assertion) confirmed unrelated by diffing the fail list against a clean checkout.

Known gap carried over from the original PR: still no browser test for the picker or the settings CRUD panel — worth a Playwright pass before merge, same as any other frontend PR.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG

Wiki

docs/wiki/Custom-Model-Endpoints.md (auto-synced to the live GitHub wiki on push to master, per docs/wiki/Contributing.md) covers turning the feature on, adding/discovering an endpoint, what a Run-menu entry actually does end to end (including the swap-conflict, context-floor, and after-the-fact-displacement warnings, and the loading banner's live backend status + Cancel button), the per-harness confidence table, and what it deliberately doesn't do yet (remote/Docker sessions, live hot-swap). Linked from the sidebar, Agent-CLIs.md, and Settings-Reference.md.

@opticon454
opticon454 marked this pull request as draft September 15, 2026 06:28
opticon454 added a commit to opticon454/Codeman that referenced this pull request Sep 15, 2026
CI on PR Ark0N#430 failed test/server-index-title.test.ts's byte-identity
check: renderIndexHtml now injects a second unconditional <script> before
</head> (window.__codemanCustomModelClis, added alongside the existing
__codemanCliAvailable one), and the test only knew to strip the older one
before comparing the rendered HTML against the raw template.

Strip both. Unlike __codemanCliAvailable (an object, historically injected
only where something resolved), the new one is a plain array injected
unconditionally, possibly empty, so it needs stripping on every machine,
not just one with CLIs installed.

Verified the two replace() calls compose correctly against the exact
strings server.ts actually produces (simulated in isolation; this box has
no tmux, so the real WebServer-backed test file cannot run here at all --
same environment gap noted throughout this PR's review).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
@Ark0N

Ark0N commented Sep 15, 2026

Copy link
Copy Markdown
Owner

Thanks for picking this up, and for reading the merge comment on #393 as an actual invitation rather than a pleasantry. The shape is right: generating the entries off capabilities.customModelInjection instead of hardcoding a duplicate per harness is exactly what I meant, injecting the capable-CLI list at render so the frontend never carries an id list is better than what I had in mind, and hiding the section for remote and docker cases because the apply route already refuses both is the correct instinct.

It is still a draft so I am reviewing it as one. The backend, the docs and the architecture are sound. The frontend half does not currently work, and I want to be specific rather than vague about it, because none of it needs a redesign. Every item below is small and local.

You were honest up front that no browser test was possible on your box ("this box has no tmux"). That is exactly where the damage landed, so it is worth saying plainly: three of these would have shown up on a single page load.

First, credit where it is due: your last commit (38e1acfe) already fixed what was the third blocker, the red test/server-index-title.test.ts. CI is green on the current head. Three remain.

1. Every generated inline onclick is unparseable, so nothing is clickable (session-ui.js:585, and the three per-row buttons in settings-ui.js).

onclick="app.runCustomModelEntry(${JSON.stringify(cli.id)}, ...)"

JSON.stringify emits double quotes and they sit inside a double-quoted HTML attribute, so the attribute terminates at the first one. Parsed with jsdom the button comes out with onclick="app.runCustomModelEntry(" and the rest of the call shredded into junk attribute names. That does not compile, btn.onclick is null, and a click fires nothing. This hits every entry the picker generates and all three of Discover, Edit and Delete.

The repo already has the right idiom four lines away in the same file: onclick="app.deleteCase(${escapeHtml(JSON.stringify(c.name))})" (session-ui.js:3878, also :3870, :3874, :4050, :4057). escapeHtml turns the quotes into &quot;, which the attribute survives and the JS parser sees as quotes again.

Fix it that way rather than by reordering quotes, because there is a second reason: modelId is the only value in that button reaching HTML unescaped, and it comes from the remote endpoint's own /v1/models response. A model id containing > terminates the <button> early and whatever follows parses as markup. The endpoint is admin-configured so this is not a remote-attacker path, but it is live HTML injection through data the server does not control, and escapeHtml closes it for free.

2. The endpoint list is read as a bare array, but the wire carries the envelope (session-ui.js:571,576 and settings-ui.js:2509,2511). GET /api/model-endpoints returns a bare array from the handler, and then the preSerialization hook in server.ts:769-784 wraps every /api payload that is not already an envelope, arrays included. I replayed that exact hook against a Fastify route returning an array: the wire body is {"success":true,"data":[...]} and Array.isArray(body) is false. So Array.isArray(hosts) is always false in production, the Custom Endpoints section hides itself unconditionally, and _customModelHosts is always [], which means the settings panel permanently reads "No endpoints yet". Even with item 1 fixed the feature is invisible.

const hosts = await this._apiJson('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/api/model-endpoints'); fixes it; api-client.js:44 exists for this and unwraps {success,data} already. CLAUDE.md states the rule directly under External CLI modes: "run*() in session-ui.js MUST unwrap the {success,data} envelope; reading the raw shape silently breaks the run."

Worth knowing why no test caught it, since it is not your fault: createRouteTestHarness builds a bare Fastify instance with only the route module and the error handler, no preSerialization hook, so custom-model-routes.test.ts:37's expect(res.json()).toEqual([]) is correct in the harness and wrong on the wire. That is a real gap in the harness and I will look at it separately.

3. A failed launch applies the endpoint to whatever session was already open, and restarts it (session-ui.js:628-651). The comment assumes a failed run*() leaves activeSessionId null. It does not: every run*() handles its own errors and returns normally. runDeepSeek() returns early when dsh is missing or has no pane-capable profile; runClaude() wraps its body in try/catch and ends with _reportSessionLaunchError. In both cases no session was created, and the code then POSTs /api/sessions/<the session the user was already looking at>/custom-model, which points that unrelated session at the endpoint and calls restartCli(), killing the pane and relaunching the CLI. isBusy() blocks it mid-turn, but an idle session, which is most of them, gets silently re-pointed and restarted while the toast says "Pointed at ..., restarting" for a launch that never happened.

Either have the runner hand back the id it created, or snapshot before and require it to have changed:

const before = this.activeSessionId;
await runner();
const sessionId = this.activeSessionId;
if (!sessionId || sessionId === before) return;

The snapshot form is a heuristic (it also declines if a run legitimately re-selects the same session), but declining to apply is the safe side of that trade.

Two majors:

4. It bypasses the Run launch in-flight lock. runCustomModelEntry calls runClaude() and friends directly instead of going through run(), so _runInFlight is never set and #runBtn is never disabled. CLAUDE.md, Run launch synchronization: the lock exists so a double click cannot create duplicate sessions with the same w<n>-<case> name. Closing the menu at the top makes a double click on the entry itself hard to hit, but the lock guards the other direction too: clicking the main Run button while a custom-endpoint launch is still resolving starts a second concurrent launch. Set and clear _runInFlight around the call, or route through run(). Related, same function: mutating #tabCount to '1' and restoring in a finally works, but it visibly flips the user's input for the duration, and if two launches ever overlap (which the missing lock allows) the restore can stomp.

5. No test for any of the new frontend behaviour. Three of the four blockers are DOM-level facts that need no Playwright and no tmux. test/home-sessions.test.ts is the precedent: it loads a frontend module with node:vm against a fake DOM and runs inside the CI gate. A test in that shape over _refreshCustomModelRunOptions, given a fake menu, a stubbed fetch and two endpoints, asserting that a button exists and its onclick attribute parses, would have caught items 1 and 2 on your own machine.

Minors, worth doing while you are in here:

  • The hardcoded runners map contradicts the PR's own design. The point of injecting the capable list off the registry is that a CLI whose injection recipe lands later needs no frontend change, but the click handler dispatches through a hardcoded eight-entry object, so such a CLI gets a generated entry that toasts "No run function for mode X". It matches the eight capable CLIs today, so this is latent rather than broken. run() already owns this dispatch.
  • Generated entries ignore whether the CLI is installed. _refreshRunModeAvailability hides a stock entry when isCliAvailable(mode) is false; the generated ones are built afterward and never gated, so on a box with no codex the stock Codex entry is hidden while "Codex (llama.cpp)" is still offered and fails at launch. A .filter((cli) => this.isCliAvailable(cli.id)) matches existing behaviour.
  • The CRUD panel is not gated on the toggle, but both docs say it is. docs/custom-model-endpoints.md says turning the setting on reveals the panel; nothing reads customModelEndpointsEnabled for visibility, so the list, the Add button and the form always render. Gate it, rather than rewording the docs: with the feature off, the panel is a list of things that do nothing. Also loadCustomModelEndpointsForSettings() is called unconditionally from openAppSettings(), so every settings open fires the GET even with the feature off.
  • The design doc still describes the superseded UI. docs/custom-model-endpoints-plan.md section 4 still specifies a separate #customModelBtn toolbar selector, and CLAUDE.md points readers at that file as the design reference. A "superseded by the Run-menu picker" note at the top of that section keeps it honest.
  • The API key round-trips through the browser on every edit. GET /api/model-endpoints returns hosts verbatim including apiKey, and the editor re-sends it to implement "blank means unchanged". The comment saying the key is never round-tripped back is true of the input element but not of the request. That store is 0600 precisely because it holds credentials. This is pre-existing route behaviour from feat: Custom Model Endpoint Profiles (local or cloud, all harnesses) #393 rather than something you added, and the exposure is bounded, so it is not a blocker, but the clean fix lives on the server: let PUT treat an absent apiKey as "keep the stored one" and stop returning it on GET. Worth doing while this area is open. Same function: a blank field can only keep a key, never clear one.
  • No way to un-point a session. The apply route accepts {clear: true} and the bookkeeping supports it, but no UI reaches it, so the only way back to the native backend is curl or deleting the session. Your docstring knows this; the wiki page does not mention it under "What it does not do".
  • The panel offers writes to non-admins in multi-user mode. Endpoint writes are admin-only, but Add/Edit/Delete render for everyone and a non-admin gets a 403 toast. The list is already empty for them, so hiding the controls when the list is empty and the user is not an admin matches how remote and Docker hosts behave.
  • Invisible on phones. mobile-overview.js builds its own run picker from mo-mode entries rather than reusing #runModeMenu, so the section does not appear there. Not a regression and not claimed, but the docs should not say "the Run menu" without qualification.

Nits: the index.html comment names _renderCustomModelRunOptions() and the function is _refreshCustomModelRunOptions(); styles.css:16221 uses rgba(0,0,0,0.12) on .set-inline-form, and CLAUDE.md records that hardcoded black alphas turned the settings preview into a grey slab on the light skins, so use a skin token; .run-mode-custom-models gets no CSS so the menu's gap: 2px does not apply between generated entries; server.ts:1609 does JSON.stringify() into a <script> body without escaping </script>, and CliEntry.label is a 60-char string a user's own clis.json could set, so .replace(/</g, '\\u003c') costs nothing (the neighbouring __codemanCliAvailable injection is booleans only, which is why it never needed it); no zh-CN entries for the new settings group; and /api/model-endpoints plus defaultModelId are still absent from docs/api-reference.md.

The backend piece (defaultModelId, refusing a value that is not one of the endpoint's discovered models, dropping it on a fresh discovery) is good and I have no notes on it.

Items 1, 2 and 3 are what I need before this comes out of draft. It is not going into the release I am assembling now, which is fine for a draft. Ping me when it is ready and I will take another pass.

Ark0N pushed a commit that referenced this pull request Sep 15, 2026
… it never had

The wiki was written for seven run modes and never received Grok Build, DeepSeek
Harness or OMP. They now appear everywhere the others do: the modes table and
per-CLI notes, install commands, environment prefixes, the Quick Start table, the
requirements rows, the vocabulary, and every "seven modes" count.

The 1.27 to 1.29.0 changes land on the pages that own them: attaching a case to an
existing container, multi-case adoption and the copy-a-case picker (Docker Cases);
file reads over ssh in remote cases and what stays unavailable (Remote SSH Sessions,
Working With Files, Security); single-page app routing, frame recovery, localhost
links as tabs and the egress guard (Web Tabs); DeepSeek as the one non-Claude mode
with real stop/blocked signals and Approvals items, Codex's own work detection,
last-response, the model-endpoint routes and refreshed counts (HTTP API, Driving
From An Agent, Hooks, Notifications, Keeping Agents Running, Core Concepts);
Shift+drag, right-click copy, Auto Copy, the Ctrl+Z guard, font weight, the vertical
rail and its activity sort (Keyboard Shortcuts, Input And Voice, The Dashboard,
Settings Reference); the 600px phone cutoff, Codex shift arrows and iPhone Duo
(Mobile Guide); the Docker Compose route and its update rule (Installation, Running
As A Service); four new symptom entries and a "which CLIs" question (Troubleshooting,
FAQ).

Custom model endpoints are deliberately left to #430, which adds that page and edits
Agent CLIs, Settings Reference and the sidebar; these edits stay out of the regions
#430, #428 and #376 touch, and all three still merge cleanly on top.

Both READMEs: the web-tab menu entry is labelled "Add URL" in the UI, not
"Add dashboard".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
opticon454 and others added 4 commits September 16, 2026 07:01
…files

Follow-up to Ark0N#393, picking up the work Ark0N invited in his merge comment:
"generate those entries from the saved profiles rather than a fixed
duplicate per harness, and put it in a follow-up PR so this one stays the
backend... The Run-menu picker is yours if you want it."

Adds the frontend surface the backend has been waiting on:

- Run menu: a "Custom Endpoints" section lists one entry per (harness that
  supports customModelInjection, saved endpoint) pair, e.g.
  "Claude Code (llama.cpp)". The harness list comes from
  window.__codemanCustomModelClis, injected at page render straight off the
  CLI registry's own capabilities (never a hardcoded id list in the
  frontend), so a CLI whose injection recipe lands later appears with no
  frontend change. Picking an entry runs that harness's own existing run*()
  function unmodified (case creation, env overrides, everything, forced to
  a single instance) and then applies the endpoint's default model to the
  session it creates via the existing POST /api/sessions/:id/custom-model
  route. Entries are hidden for a remote/docker active case, since that
  route already refuses both.
- Settings: App Settings -> Models gets a "Custom model endpoints" group
  wiring up the customModelEndpointsEnabled toggle (declared since Ark0N#393,
  read by nothing until now) plus CRUD against the existing
  /api/model-endpoints routes: list, add/edit (inline form), delete,
  discover models.
- Backend: CustomModelHost gains an optional defaultModelId, the model the
  picker applies with no further choice per endpoint (one generated menu
  entry per CLI+endpoint pair, not per CLI+endpoint+model). The route
  refuses a value that isn't one of the endpoint's own discovered models,
  and a fresh discovery drops a default that no longer appears rather than
  carrying an invalid one forward.

Docs: docs/custom-model-endpoints.md describes the new picker and settings
panel; CLAUDE.md's Custom Model Endpoint Profiles entry drops the
"backend-only" status note and documents the picker's generation mechanism.

Tests: four new route tests cover defaultModelId validation, acceptance,
and the drop/keep behaviour across a re-discovery; a new render-index-html
test pins the __codemanCustomModelClis injection (present, agent CLIs
supporting the capability, antigravity and shell excluded) and its
solo-window skip. No browser test was added for the Run-menu picker itself
or the settings CRUD panel (this box has no tmux, so the live server used
by test:browser/test:mobile could not be exercised here) -- worth a
Playwright pass before merge, same as any other frontend PR.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
New docs/wiki/Custom-Model-Endpoints.md (auto-synced to the live GitHub
wiki on push to master, per docs/wiki/Contributing.md) covers turning the
feature on, adding an endpoint, the Run-menu picker's one-off-run
behaviour, the per-harness confidence table, and what it deliberately does
not do yet (remote/Docker sessions, live hot-swap). Linked from the
sidebar, from Agent-CLIs.md's "Read next" list plus a short pointer
section, and from Settings-Reference.md's Models section.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
CI on PR Ark0N#430 failed test/server-index-title.test.ts's byte-identity
check: renderIndexHtml now injects a second unconditional <script> before
</head> (window.__codemanCustomModelClis, added alongside the existing
__codemanCliAvailable one), and the test only knew to strip the older one
before comparing the rendered HTML against the raw template.

Strip both. Unlike __codemanCliAvailable (an object, historically injected
only where something resolved), the new one is a plain array injected
unconditionally, possibly empty, so it needs stripping on every machine,
not just one with CLIs installed.

Verified the two replace() calls compose correctly against the exact
strings server.ts actually produces (simulated in isolation; this box has
no tmux, so the real WebServer-backed test file cannot run here at all --
same environment gap noted throughout this PR's review).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
…rapped envelope, wrong-session apply, missing lock, no tests

Addresses every blocker, both majors, and all but one minor from the
maintainer's review of the draft PR.

Blockers:

1. Every generated inline onclick was unparseable. JSON.stringify's own
   double quotes terminated the double-quoted HTML attribute at the first
   one, leaving btn.onclick null on every picker entry and every Discover/
   Edit/Delete button. Fixed with escapeHtml(JSON.stringify(...)) per
   argument, the same idiom deleteCase's onclick already uses four lines
   away in session-ui.js. This also closes the live-HTML-injection route
   through modelId (server-controlled, from the endpoint's own /v1/models
   reply): with quoting intact, a `>` inside it can no longer terminate the
   <button> tag early.
2. GET /api/model-endpoints wraps its body in the {success,data} envelope
   like every other /api route (server.ts's preSerialization hook applies
   to arrays too), so Array.isArray(hosts) was always false in production
   and the picker/settings panel silently saw nothing. Both call sites now
   go through _apiJson(), which already exists for exactly this.
3. A failed or declined run*() (missing CLI, isBusy, a caught exception)
   returns normally without ever changing activeSessionId, so the apply
   step used to silently re-point and restart whatever session the user was
   already looking at. runCustomModelEntry() now snapshots activeSessionId
   before the launch and requires it to have actually changed.

Majors:

4. Routes the launch through run() itself via a temporary _runMode swap
   (never persisted — setRunMode() would sync it to the server) instead of
   a parallel hardcoded dispatch table, so a custom-model launch now holds
   the same _runInFlight lock every other Run click gets. This also
   resolves the "hardcoded runners map contradicts the PR's own design"
   minor: dispatch is run()'s own, so a CLI whose customModelInjection
   recipe lands later needs no update here.
5. New test/custom-model-run-menu-ui.test.ts drives the real session-ui.js
   against a JSDOM window (runScripts:"dangerously" — this JSDOM only ever
   parses markup this module generated itself) for exactly the DOM-level
   facts the review said needed no Playwright and no tmux: a generated
   button's onclick genuinely compiles and fires, a dangerous modelId never
   produces a live element, the envelope unwrap works, the session-changed
   guard holds, run() actually gets called (proving the in-flight lock
   engages), and _runMode is restored afterward. Confirmed against the
   pre-fix code first (reproduces btn.onclick === null exactly) so this
   isn't a vacuous pass. Plus new tests in custom-model-routes.test.ts and
   render-index-html.test.ts for the other fixes below.

Minors:

- Generated entries now filter through isCliAvailable(), matching
  _refreshRunModeAvailability's own gating of the stock entries.
- The CRUD panel is now gated on customModelEndpointsEnabled
  (applyCustomModelEndpointsVisibility(), wired to the toggle's onchange
  and to settings-modal open) instead of always rendering; the endpoint GET
  no longer fires unconditionally either.
- API keys are never handed back to the browser on GET, POST or PUT —
  redactApiKey() replaces the field with a computed apiKeySet: boolean, and
  a PUT with no apiKey now keeps the stored one server-side
  (applyStoredApiKey()) instead of the client resending a value it was
  never given. New tests cover both directions (kept vs. replaced) by
  observing the actual auth header a subsequent discovery request sends.
- "+ Add endpoint" hides for a non-admin in multi-user mode
  (_applyCustomModelAdminGate(), also wired to admin-ui.js's codeman:me
  event, since the real role can resolve after settings were first opened)
  — endpoint writes were already admin-only server-side, but the button
  used to render for everyone and eat a 403.
- design doc (custom-model-endpoints-plan.md §4) now says up front that its
  toolbar-button design was superseded by the Run-menu picker.
- docs/api-reference.md gained a Custom Model Endpoints section (every
  route, the apiKeySet/defaultModelId contract, the restart mechanics).
- Wiki page now covers un-pointing a session (curl/delete, no UI yet) and
  that the picker is desktop-only for now.
- .set-inline-form uses --control-bg instead of a hardcoded black alpha
  (CLAUDE.md already records that exact literal turning the settings
  preview into a grey slab on light skins), .run-mode-custom-models gets
  the same gap: 2px .run-mode-menu's own flex gap only applies one level
  up, and the index.html comment naming the wrong function is fixed.
- __codemanCustomModelClis's JSON is now escaped against a literal
  </script> (CliEntry.label is user-clis.json-settable, unlike
  __codemanCliAvailable's booleans-only payload) via a new exported
  escapeScriptJson(), pure and unit-tested without needing a WebServer.
- Added defaultModelId + the new /v1/model-endpoints routes to
  docs/api-reference.md; left the "no zh-CN for the new Models-section
  group" minor unaddressed only insofar as the wider Models section (task
  routing, thinking effort, etc.) has never had zh-CN coverage either —
  everything this PR itself introduces (labels, hints, button text, the
  Run-menu's "Custom Endpoints" header) IS translated in i18n.js.

Regression caught while fixing Ark0N#4: the admin-gate's codeman:me listener is
a module-level document.addEventListener() call, which threw in
run-mode-ui.test.ts's minimal vm-context fake document and failed all 10
of that file's tests. Fixed with optional chaining before it ever reached
the branch this commit lands on; full targeted suite (route tests,
structural guards, every settings-ui.js-loading frontend test) reverified
green afterward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
@opticon454
opticon454 force-pushed the feature/run-menu-custom-model-picker branch from 3a7146b to 60e1bd5 Compare September 15, 2026 23:02
opticon454 and others added 4 commits September 16, 2026 08:50
…re than one, and re-discover models every 5 minutes

Two enhancements requested after live-validating PR Ark0N#430 against a real
llama.cpp server:

1. Model picker dialog. Picking a Run-menu Custom Endpoints entry used to
   apply the endpoint's defaultModelId (or the first discovered model)
   silently. Now, via the new selectCustomModelEntry() (session-ui.js):
   - exactly one discovered model launches straight away, same as before
   - two or more open a new #customModelPickModal listing every discovered
     model; defaultModelId (if set) is marked but never auto-chosen, since
     the point of asking is letting ONE launch deliberately differ from
     the saved default, not just confirming it
   The endpoint is re-fetched at click time rather than trusting anything
   cached from the dropdown's own render, since the model list can have
   changed (the sweep below, or a settings-panel edit) since it opened.
   runCustomModelEntry() itself — the actual launch, routed through run()
   for the in-flight lock, snapshot-guarded against applying to the wrong
   session — is unchanged; it now just always receives an explicit model
   id from one of these two paths instead of computing one itself.

2. Periodic re-discovery. Every saved endpoint's models now refresh
   automatically every 5 minutes in the background
   (CUSTOM_MODEL_REDISCOVER_INTERVAL_MS, server.ts, registered the same way
   as the Codex plan-usage poll it sits beside — this.cleanup.setInterval,
   off under testMode), so a model the server starts or stops serving shows
   up without another manual "Discover" click. The manual POST
   .../discover-models route and the new refreshAllCustomModelHosts()
   sweep (custom-model-routes.ts) now share one pure merge step
   (applyDiscoveredModels: stamps lastDiscoveredAt, drops a defaultModelId
   that no longer appears) rather than two copies that could drift. The
   sweep is best-effort per host — one endpoint being unreachable on a
   cycle never blocks the others — and re-reads the store before each
   host's write, keyed by id, so a concurrent edit or delete from the
   settings panel always wins over a sweep that started before it.

Tests: test/custom-model-endpoint-rediscovery.test.ts is a new, dedicated
file for the sweep (kept separate from custom-model-routes.test.ts because
that file's data dir is shared across every test in it — one temp HOME per
FILE, not per test — which would make a sweep-touches-every-host assertion
meaningless there). test/custom-model-run-menu-ui.test.ts gained a new
describe block driving the real picker modal through JSDOM: single-model
bypass, multi-model dialog with the default marked-not-chosen, picking a
row closes the modal and launches with that exact model, the endpoint
re-fetch, and the two "vanished by click time" toast paths.

Docs: docs/custom-model-endpoints.md, docs/wiki/Custom-Model-Endpoints.md,
docs/api-reference.md and CLAUDE.md's dense feature paragraph all updated
— the last of these also caught up two sentences that had gone stale after
the draft-review fixes landed (the picker routes through run() now, not a
raw run*() call).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
…s list scroll

The dialog had no max-height at all, so an endpoint with many discovered
models grew it past the viewport with nothing to scroll — reported live as
both "takes up the full page" and "the list is truncated", which turn out
to be the same bug. Gives #customModelPickModal .modal-content the same
bounded-height + scrollable-body shape cronModal's .modal-lg already uses
(max-height + flex column on the content, overflow-y:auto + flex:1 on the
body), scoped by id rather than folded into the shared .modal-sm class
three other modals already use for short, fixed content.

max-height: min(70vh, 520px) scales with the viewport (a phone gets 70% of
its height; a 4K display never gets a needlessly tall dialog) rather than
committing to one fixed pixel value that would be wrong at either end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
… toasts sticky with a close button

Two related fixes, both needed to actually diagnose 'Session started on
the native backend — could not apply the custom endpoint' reports from
live testing:

1. runCustomModelEntry()'s apply call went through _apiJson(), which
   unwraps a success body but SWALLOWS a failure response entirely and
   returns null — discarding the one thing (error, errorCode) that would
   tell 'endpoint unreachable' apart from 'not a discovered model',
   'remote/Docker session', or a dozen other real causes the apply route
   already reports distinctly. Switched to _api() so the actual response
   body is read on failure too, and the toast now includes the real
   message.
2. showToast() defaulted every toast, error or not, to a 3s auto-dismiss
   with no way to read it again — exactly what made the above generic
   message impossible to act on even before the fix above. Error toasts
   now default to sticky (duration: 0, no auto-dismiss) unless a caller
   opts into a duration, and every toast — sticky or not — gets an
   explicit close (x) button, since a sticky toast with no way to
   dismiss it would just accumulate across repeated failures.

Tests: custom-model-run-menu-ui.test.ts's two apply tests updated for the
_api() switch (their mocks previously stubbed _apiJson, which the apply
call no longer goes through), plus a new test pinning that the real
server error string reaches the toast on a failure.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
…ore applying

Root cause of every 'Session is busy' apply failure reported from live
testing: a just-launched CLI reports itself 'busy' for its own startup
(boot spinner, workspace-trust check) well before runCustomModelEntry's
apply call could reach it, and the apply route's isBusy() guard correctly
cannot tell that apart from a real turn in progress — it exists precisely
to refuse restarting a session mid-turn, and a fresh boot looks exactly
like one from the outside. Confirmed live: replaying the identical apply
call by hand against the same session, once it had settled, succeeded
immediately.

Fixed by waiting on the session's own readiness signal before applying:
GET /api/sessions/:id/wait?until=idle&timeout=20000, one GET already built
for exactly this ('Agent wait primitives', CLAUDE.md) rather than inventing
a client-side poll loop. A timeout there is a normal 200 per that
endpoint's own contract, never an error, so a session still busy after 20s
just reaches the apply call anyway and gets the route's own honest error —
now visible, since the previous commit made error toasts sticky and
stopped discarding the real error text.

Tests: new case in custom-model-run-menu-ui.test.ts pins the ordering (the
wait call happens, and strictly before the apply call) and its exact query
string.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
@opticon454

Copy link
Copy Markdown
Contributor Author

I've been manually validating this one, it might take a few days as it's having problems injecting the right keys & context windows for different models when using llama-swap etc.
Stay tuned :)

opticon454 and others added 16 commits September 16, 2026 12:08
…length

Addresses two live-validation findings on the Run-menu custom-model picker:

1. Both claude.ai and ANTHROPIC_API_KEY set warning. Claude Code still
   coexists an OAuth login with an injected ANTHROPIC_API_KEY in the same
   config directory and warns about it (confirmed cosmetic - the API key
   wins for actual requests, verified via a real session's own API Usage
   Billing line). A custom-model claude session now gets an isolated
   CLAUDE_CONFIG_DIR (registry-declared via a new configDirVar field, empty,
   no files written into it) so there is nothing to conflict with. projects
   is symlinked (junction on Windows) back into the real config dir so the
   response viewer, subagent windows and Read My Mind keep working for that
   session, best-effort.

2. Context-window overflow. Claude Code assumes a large default context
   window for a model id it doesn't recognise and never compacts, so a
   custom endpoint's real, much smaller context (verified live: a 400
   exceeding a 16384-token llama-swap model with a stock ~33.7K-token system
   prompt) silently overflows. Discovery now also learns each model's real
   context length from llama.cpp/llama-swap's GET /props?model=<id> (n_ctx),
   but ONLY for a model llama-swap's own /v1/models response already marks
   status.value === 'loaded' - never an unloaded one, since llama-swap
   treats ?model= as a routing hint and probing an unloaded model risks
   triggering an actual, slow, GPU-swapping load as a side effect of
   read-only discovery. A server with no status field at all gets no
   enrichment rather than a guess; a model not probed this round keeps its
   previously-learned value until it disappears from the list entirely.
   Stored per model (CustomModelHost.modelContextLengths) and applied via a
   new contextLengthVar registry field, set to
   CLAUDE_CODE_MAX_CONTEXT_TOKENS for claude.

Both new fields live on the existing env-kind customModelInjection
capability shape, declared only on claude's registry entry - every other
CLI's injection is unaffected (pinned by test).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
…laude config dir

The CLAUDE_CONFIG_DIR isolation from the previous commit fixed the cosmetic
auth warning but introduced a real regression: an otherwise-empty config
directory has none of a real profile's prior custom-API-key approvals, so
Claude Code stops at an interactive 'Detected a custom API key - use it?'
prompt on every single launch. Confirmed live. With nobody at a TTY to
answer, the prompt's own default ('No') silently refuses the very key this
feature just injected, which looks like the endpoint being ignored.

Adds apiKeyTrustFile to the env-kind customModelInjection capability shape
({relPath, shape: 'claude-api-key-responses'}), set on claude's entry to
{relPath: '.claude.json', shape: 'claude-api-key-responses'}. The apply step
merges customApiKeyResponses.approved: [apiKey] into
<isolatedConfigDir>/.claude.json - the exact field a real answered prompt
itself writes to (confirmed against a real ~/.claude.json after answering by
hand once), so this answers the prompt in advance rather than bypassing it.
Merges onto whatever the CLI already wrote into that file on an earlier
launch in the same isolated directory rather than overwriting it; a missing
or corrupt file is treated as empty rather than failing the apply.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
…_CONFIG_DIR isolation

Fixes the CI failure on the last two commits: this route test asserted an
exact envKeys list for a claude-mode apply that predates the
CLAUDE_CONFIG_DIR isolation fix, so it failed on the new CLAUDE_CONFIG_DIR
entry it correctly started appending. Updates the expected list and adds
assertions for the isolated config dir path and the pre-seeded
.claude.json trust-approval file, matching the behavior added in the two
prior commits rather than just tolerating it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
Root-caused the user's earlier confusion ('the terminal says opus even though
something is waiting for llama to load'): llama.cpp runs exactly one model at
a time, and llama-swap unloads/reloads it on demand - a swap can take
anywhere from a few seconds to well over a minute, during which a session
looks indistinguishable from one still on the native backend.

1. Feature-detects llama-swap (vs. plain llama.cpp/any OpenAI-compatible
   server) via its own GET /running, which plain llama.cpp has no concept of
   at all. New GET /api/model-endpoints/:id/running-status route exposes this
   read-only, for the frontend's polling loop below.

2. Before applying a selection, POST /api/sessions/:id/custom-model now checks
   what llama-swap currently has loaded. If it differs from the requested
   model AND another live session's own customModel selection is actively
   using that loaded model, the apply is refused with a
   {requiresConfirmation, currentlyLoadedModel, affectedSessions} payload
   instead of silently switching. A "confirmed: true" field on the retry
   skips the check. Switching with nothing else affected proceeds
   immediately, no confirmation asked, only ever when there is something to
   warn about.

3. The frontend (runCustomModelEntry) shows a native confirm() naming the
   affected session(s) and the model they'd lose, matching this codebase's
   existing convention for this class of decision (delete case, kill
   session, etc.) rather than a new modal. On a successful apply the response
   also carries modelSwapInProgress; when true, a new _watchLlamaSwapLoading
   poll shows a sticky "Loading <model>..." toast via the new running-status
   route until llama-swap reports the target model ready (bounded at 2
   minutes), so a prompt sent mid-swap reads as "loading", never as silence
   or an answer from whatever was loaded a moment before.

Checks are read-only against llama-swap's own /running - never /props, which
takes a ?model= and can itself trigger a load as a side effect of asking.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
Covers Ark0N#430's full scope so far: the picker itself, the model-selection
dialog, periodic re-discovery, and the session-busy/toast/CLAUDE_CONFIG_DIR/
context-length/llama-swap-conflict fixes found through live validation
against a real llama-swap server.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
…7 of 8 CLIs

Fixes the visible double-launch reported on Codex: picking a custom-model
Run-menu entry launched natively first, waited for it to settle, then
restarted it in place with the endpoint applied. Necessary for the design at
the time, but visibly a native boot immediately followed by a second one -
worst on a CLI whose TUI fully reinitializes on a restart, confirmed live on
Codex.

POST /api/quick-start gains an optional customModel field
({endpointId, modelId, confirmed?}). When present, the route mints the
session's id itself (crypto.randomUUID()) before constructing it, computes
the same injection the existing POST /api/sessions/:id/custom-model route
computes (including the llama-swap conflict check from the last commit -
same {requiresConfirmation, currentlyLoadedModel, affectedSessions} shape,
no session created until confirmed), and launches the session already
pointed at the endpoint: env vars via the constructor, and the launchModel
override merged onto piConfig/grokConfig/ompConfig using the registry's own
launch.legacyConfigField the same way session.ts's restart path already
does. No restart at all - setCustomModel() afterward is bookkeeping only.

Wired into 7 of 8 launch functions (session-ui.js): openCode, codex, gemini,
pi, grok, deepseek, omp. Claude stays on the original launch-then-restart
path for now: its own --resume-based restart is far less jarring than the
other seven's, and runClaude()'s multi-tab launch plus docker-config-drift
confirm/retry loop make folding it into the one-shot path separate,
higher-risk work than the other seven's each-a-single-simple-launch shape.

Also fixes a pre-existing 'mode === omp' branch flagged by the CLI-id
static guard (test/cli-registry-no-id-branching.test.ts) - the ompConfig
launchModel merge is the same 'legacy <Mode>Config plumbing' category as
the six sibling branches already allowlisted there, just newly literal
where it was previously only inside resolveOmpConfigForCreate's own check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
…en-restart window

Claude stays on the launch-then-restart path (see runCustomModelEntry's own
comment for why), but with nothing on screen during that window, a native
boot that briefly talks to the cloud model read as "the endpoint didn't
apply" rather than "the switch hasn't happened yet".

A sticky "Claude started - switching to <endpoint>..." toast now covers the
whole window from the native launch through the apply call, updated in
place (never stacked) as the outcome resolves: dismissed on cancel or
failure (replaced by the existing cancellation/error toast), handed off to
_watchLlamaSwapLoading's own sticky toast when a model swap is in progress,
or updated to the existing "Pointed at ... - restarting" message and
auto-dismissed after 3s on a plain success.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
…modal

The llama-swap "this will unload it for session X" warning used a native
browser confirm() popup, which looks out of place next to the rest of the
app's own modals.

Adds #customModelSwapConfirmModal (index.html) with Cancel/Switch-anyway
buttons, styled to match the app. _confirmModelSwap(message) shows it and
returns a promise that resolves true/false the same way confirm() would;
_resolveModelSwapConfirm(proceed) (wired to both buttons and the backdrop
click) settles it. Both llama-swap conflict call sites
(_quickStartWithCustomModelConfirm for the one-shot launch path,
_runCustomModelEntryViaRestart for Claude's restart path) now await this
instead of calling confirm() directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
The "Claude started - switching to <endpoint>..." and "Loading <model> on
<endpoint>... this can take a while" messages lived in the top-right toast
corner along with everything else, easy to miss given they can each sit on
screen for well over a minute (a real llama-swap model load).

Adds _showCenterStatus() (panels-ui.js): a single, reused, screen-centred
banner with a spinner, non-blocking (no backdrop, pointer-events: none on
the wrapper) so it never gets in the way of using the app while it's up.
Both call sites (_runCustomModelEntryViaRestart's switching message,
_watchLlamaSwapLoading's loading message) now use it instead of showToast.
Every OTHER status in these two flows - the llama-swap conflict warning
already moved to its own modal, apply failures, cancellation, and
_watchLlamaSwapLoading's own final "ready"/"still waiting" outcome - stays
exactly where it was, in the corner.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
…ch for it

Root cause of "it doesn't look like llama-swap is actually switching the
model" (confirmed live: no load_model line in llama-swap's own logs after
applying a selection). llama-swap has no "switch model" admin endpoint - the
ONLY thing that starts a swap is a real inference request naming the model.
Every previous fix (the conflict check, the loading banner) assumed a swap
would start on its own; nothing ever actually asked llama-swap to load
anything until the launched CLI's first real prompt did, which could be
much later than "applying the selection" implied.

Adds triggerLlamaSwapLoad() (custom-model-routes.ts): sends the smallest
real request that will start a load - POST <baseUrl>/v1/chat/completions,
max_tokens: 1, one throwaway message - fire-and-forget (never awaited by
the caller; the frontend's own running-status polling is what actually
confirms readiness). Wired into both apply paths (the dedicated restart
route and the one-shot quick-start route), fired whenever the target model
isn't already the one loaded and ready - a broader condition than the
existing swapNeeded (which only gates the "this will evict another
session's model" confirmation ask and deliberately stays narrow to that).
modelSwapInProgress in both routes' responses now reflects this same
broader condition too, so the frontend's loading banner actually correlates
with a real in-flight load rather than only firing when something else
happened to be loaded already.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
…ely, extend the cap

Reported: the "Loading..." banner stayed up past 2 minutes even though
llama-swap itself had already finished loading the model. Three fixes:

1. pollIntervalMs default 3000ms -> 1000ms (as asked).
2. The loop now checks readiness IMMEDIATELY on entry rather than sleeping
   a full interval first - a model that's already ready (a fast load, or a
   re-apply onto one already loaded) shouldn't sit on "Loading..." at all.
3. maxWaitMs default 120000ms (2 min) -> 300000ms (5 min): a large (20GB+)
   model reading from disk can genuinely take longer than 2 minutes, which
   would have looked identical to the reported symptom - "still stuck past
   the point it should have resolved" - except it would have actually
   flipped to a "still waiting" warning toast at the 2-minute mark rather
   than staying on "Loading" indefinitely, so this alone doesn't explain
   what was reported, but is a real, separate improvement worth making.

Also fixes a real, separate bug this surfaced while reasoning through the
report: _showCenterStatus's banner is ONE shared, reused DOM node. A second
call to _watchLlamaSwapLoading (e.g. switching models again before the
first switch's loop had finished) would take over that shared banner, but
the FIRST loop was still running and would eventually dismiss or overwrite
it once ITS OWN deadline or readiness check resolved - clobbering whatever
the second, current loop had put there. A generation counter
(_watchLlamaSwapGeneration) now lets each call recognise when it no longer
owns the banner and stop touching it silently, rather than only the last
call to actually start ever safely reading or writing it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
Discovery now also parses a GB figure out of an auto-discovered model's own
description (llama-swap writes "Auto-discovered 16.35 GB - parameters
auto-fitted by llama.cpp"), stored per model as modelSizesGB - unlike
context length this needs no /props probe (the figure is right there in
/v1/models) so it is populated for every model regardless of loaded state.
A hand-configured profile's own description has no such figure and
correctly gets no entry.

The loading banner (_watchLlamaSwapLoading) now looks this up and, when
known, shows it plus a rough estimate from a small size->time matrix
(_estimateModelLoad/_MODEL_LOAD_TIME_MATRIX, session-ui.js) -
"Loading qwen3.8-27b-ud-q4_k_xl (16.4 GB, typically ~1-3 min) on
llama-swap... this can take a while" - and uses that same estimate's own
bracket to scale the banner's default give-up timeout for a very large
model, instead of a flat 5 minutes for everything. Explicitly labelled as
an UNMEASURED, typical-hardware estimate in every relevant comment - this
is not benchmarked against any real endpoint's actual storage/GPU, just a
reasonable expectation-setter. A model with no discoverable size (a
hand-configured profile) gets no size/estimate shown at all, matching the
"never a guess" convention modelContextLengths already established.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
…now an error

The loading banner now shows a live countdown against its own timeout
(updated every poll, so every second by default) instead of a static
"this can take a while" — e.g. "Loading qwen3.8-27b (16.4 GB, typically
~1-3 min) on llama-swap - 47s remaining".

If the countdown reaches zero and the model still isn't ready, this is now
treated as a real failure rather than a "keep waiting" shrug:
- The banner turns into a sticky error (_showCenterStatus gains a `type`
  option - 'error' drops the spinner and adds a close button, since nothing
  is "in progress" anymore and a sticky message needs a way to dismiss it),
  naming the llama-swap server's own logs as where to look for detail.
- The session that load was for is closed automatically (closeSession) -
  requested explicitly: a console left open and pointed at a model that
  never finished loading is worse than no console at all. Both apply paths
  now thread the new session's id through to _watchLlamaSwapLoading for
  this (new required 3rd parameter, after endpointId/modelId).

_watchLlamaSwapGeneration's existing stale-call guard extends naturally to
this: a superseded call's own eventual timeout recognises it no longer owns
the banner and neither shows the error nor closes a session that may by
then belong to a different, newer launch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
…xt size from /running's cmd

Root cause of the context-overflow regression reported live: "API Error: 400
request (36437 tokens) exceeds the available context size (16384 tokens)".
Discovery had stored modelContextLengths.qwen3.8-27b-ud-q4_k_xl = 154112,
so CLAUDE_CODE_MAX_CONTEXT_TOKENS told Claude Code it had a huge window and
it never compacted - but the real llama-swap server was launched with
--fit-ctx 16384 (confirmed against /running's own cmd field) and refused
the request right at that real limit.

/props?model=<id>'s n_ctx (the field discovery read) is confirmed live to
be unreliable for a --fit-ctx-launched backend: it reported 154112 for the
same model /running says was launched with --fit-ctx 16384 - appears to
report the model's theoretical/trained maximum context, not the runtime-
configured one.

discoverModels() now parses the REAL configured size straight out of
llama-swap's own launch command instead (parseCtxFromCmd(), reading
/running's cmd field - --fit-ctx first, then the plain llama.cpp -c/
--ctx-size a hand-written command might use), and only falls back to the
old /props probe when cmd states no recognizable flag at all. One /running
call now covers every loaded model's context length in a single request,
same as it already did for the swap-conflict check and the load trigger.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
… for its own overhead

Claude Code's own fixed per-turn overhead (system prompt + tool schemas,
~36.4K tokens measured live) can exceed a small local model's entire real
context before any conversation history exists to compact — confirmed
live twice as an in:0 out:0 failure on the very first message sent.
CLAUDE_CODE_MAX_CONTEXT_TOKENS cannot fix this: it only governs when
history gets compacted, and there is none on message one.

- exceedsSafeContextFloor() (custom-model-routes.ts): true when a CLI's
  registry entry declares contextLengthVar (currently only claude) and
  the model's discovered context is below CLAUDE_MIN_SAFE_CONTEXT_TOKENS
  (40000). A no-op for every other CLI by construction.
- Both apply routes (POST /api/sessions/:id/custom-model and the
  quick-start customModel path) check this before the swap-conflict
  check and before launching/restarting anything, returning
  {requiresContextWarning, modelId, contextLength, minSafeContextTokens}
  — skipped when confirmed:true.
- Frontend: #customModelContextWarningModal + _confirmContextWarning/
  _resolveContextWarningConfirm (session-ui.js), wired into both
  _quickStartWithCustomModelConfirm and _runCustomModelEntryViaRestart
  (the path Claude actually uses) ahead of the swap-confirmation check.
  Explains the fix in-modal: give the model an explicit larger -c/
  --ctx-size in llama-swap instead of relying on --fit-ctx, which
  optimizes for the biggest model that fits rather than the biggest
  context.

Tests added for the route-level warning/confirm/skip cases and the
frontend modal + launch-flow wiring. Docs updated (custom-model-
endpoints.md, wiki/Custom-Model-Endpoints.md) and the PR's running
changeset extended.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
…status banner

Both dialogs can appear while the centred llama-swap status banner is
still on screen (right after "Claude started — switching to
llama-swap…") — the banner's z-index is 10001, .modal's base z-index is
only 1000, so the dialog rendered fully behind it. Reported live against
the context-window-too-small modal; the swap-confirm modal has the same
structural bug for the same reason, so both get the fix.

Also: both messages ARE the modal's whole explanatory content, not a
one-line caption under a form field, so .form-hint's 0.65rem caption
size read as illegibly small — worst on the multi-sentence
context-window explanation. Bumped to 0.85rem/1.5 line-height/--text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
opticon454 and others added 7 commits September 17, 2026 09:21
…el launches

A fresh, isolated CLAUDE_CONFIG_DIR (used to keep an injected API key
away from a stored claude.ai OAuth login) looks like a brand-new Claude
Code profile to the CLI, so it replays its ENTIRE first-run sequence on
every single launch: the theme picker, the security-notes screen, the
per-project "trust this folder?" dialog, and (running with
--dangerously-skip-permissions) a one-time bypass-permissions warning —
confirmed live, none of which a real, already-onboarded profile shows
again.

- New registry-declared env-kind field `skipFirstRunPrompts` (alongside
  apiKeyTrustFile, which it reuses) — claude's entry only, carried
  through buildCustomModelInjection (pure) into
  applyCustomModelInjection (IO).
- seedFirstRunOnboardingState(): merges hasCompletedOnboarding: true and
  this session's own projects[workingDir].hasTrustDialogAccepted: true
  into the same <configDir>/.claude.json the API-key trust file already
  writes to — other projects and other fields on this session's own
  entry are left untouched.
- seedSkipBypassPermissionsPrompt(): merges
  skipDangerousModePermissionPrompt: true into <configDir>/settings.json,
  a separate file, same corrupt-tolerant merge behavior.
- applyCustomModelInjection() gains an optional workingDir parameter,
  threaded from session.workingDir (dedicated apply route) /
  resolvedCasePath (quick-start route) — boot recovery omits it
  (a dialog already answered once needs no re-seed on the same,
  persisted isolated directory).

Tests added at the pure-builder, IO-wrapper (including merge-preserves-
other-fields and corrupt-file-tolerance cases), and existing directory-
listing assertions updated for the new settings.json file. Typecheck/
lint/format clean; full suite shows no new regressions (baseline
pre-existing Windows-environment failures unchanged, 8 more passing
tests than before — the ones added here).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
…rning

Investigated the user's report of "Model metadata for <id> not found.
Defaulting to fallback metadata..." on every custom-endpoint codex
launch, live against the test-picker's llama-swap deployment (codex
0.152.1):

- The warning is cosmetic. `codex exec 'reply with just OK'` against the
  isolated CODEX_HOME still printed the warning and still returned a
  real reply.
- The isolated CODEX_HOME never gets a models_cache.json written into
  it at all, even after extended real use (inspected a live, actively-
  used directory) — codex can't reach OpenAI's own hosted model catalog
  for this session and silently falls back every time, with no local
  file to create or clean up. There is also no config.toml override for
  a model's metadata.
- Fabricating a fake catalog entry to suppress it would mean copying the
  SHAPE of OpenAI's own proprietary models_cache.json schema, including
  real per-model system-prompt content visible in a genuine entry — not
  something to build for a warning confirmed to have no effect.
- More importantly: a real tool-call attempt against the same setup came
  back as agent_message TEXT (the tool-call JSON printed as the answer)
  rather than an executable function_call item, confirmed via
  `codex exec --json`'s raw event stream. Tool execution is what makes
  codex a coding agent, so it remains not usable for real work regardless
  of the metadata warning — a more precise, re-verified update to the
  existing "Responses API protocol gap" finding (which reported a harder
  Reconnecting/high-demand failure on a different llama-swap deployment;
  this one answers /v1/responses for plain chat but still can't execute
  tools).

No code changes — recipe/comment/confidence-table documentation only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
…pped out later

The llama-swap conflict check on the apply/create routes only ever runs
at THAT session's own launch/apply moment, and cannot see a swap caused
by a DIFFERENT session's later, ordinary use. Confirmed live: a second
Codex session picking a different model launched with no warning at
all — nothing conflicted at that exact instant — yet it silently
evicted the first session's model regardless (llama.cpp runs one model
at a time). Reproduced and root-caused via direct API calls against a
live test-picker instance rather than guessing.

- detectCustomModelSwapDisplacements() (custom-model-routes.ts): groups
  live sessions with a customModel by endpointId, checks each group's
  endpoint via GET /running once, and flags a session whose own modelId
  is no longer in the running list. Read-only, best-effort per endpoint
  like refreshAllCustomModelHosts's sibling sweep.
- Notifies once per displacement via a caller-owned de-dupe Set: a
  session id is added when displaced, removed once its own model is
  loaded/ready again, so a later genuinely-new displacement can notify
  again.
- New periodic sweep in server.ts (CUSTOM_MODEL_SWAP_CHECK_INTERVAL_MS,
  20s — much shorter than the 5-minute model-list refresh, since this
  is time-sensitive) broadcasts a new custom-model:swapped-out SSE
  event per displacement. De-dupe Set cleared per-session on session
  cleanup to avoid an unbounded leak.
- Frontend: global toast (not tied to the displaced session's tab,
  since the point is warning before the user types into it) naming the
  session, its previous model, and what's currently loaded.

Chose the "detect after the fact" scope (vs. checking before every
message send, which would add a round-trip to every turn on every
custom-model session) per explicit user decision after being presented
the trade-off.

9 new tests for the detection logic (flag/clear/re-flag cycle,
unreachable/deleted endpoints, non-llama-swap servers, multiple
sessions on one endpoint). SSE registry bumped 158->159, parity test
passing. Typecheck/lint/frontend-syntax clean; full suite shows no new
regressions (9 more passing than baseline, matching the new tests;
same pre-existing Windows-environment failures).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
…ading banner

Answers the underlying request behind investigating llama.cpp log
access: surface what the backend is actually doing, live, on top of
the existing countdown timer during a model load.

- getLatestLlamaSwapLogLine()/pruneIdleLlamaSwapLogTails()
  (custom-model-routes.ts): one persistent GET /api/events (SSE)
  connection held open per endpoint, parsing logData frames and
  keeping the latest source:"upstream" (backend llama-server) line —
  filtering out llama-swap's own source:"proxy" request-access lines.
  Idle-closed after 30s of no polling, same 20s sweep as the existing
  swap-displacement check.
- running-status route now returns logLine alongside the existing
  isLlamaSwap/running fields.
- Frontend: _watchLlamaSwapLoading's banner gains a second line
  ("llama.cpp: <line>", bootlog timestamp/level/component prefix
  stripped for display) that stays on the last real thing llama.cpp
  said rather than clearing to blank between polls.

⚠️ Caught and fixed before merge, not after: the first cut targeted
GET /logs (the endpoint the name suggests), shipped a working-looking
implementation with passing tests, and only failed a live check against
the real Nemesis llama-swap deployment — /logs turns out to carry ONLY
llama-swap's own proxy request-access log and never once showed a
single backend line, even seconds after a real, confirmed model swap
triggered via a direct API call. GET /api/events's logData frames
(with an explicit source field distinguishing upstream from proxy) are
the only source that actually has backend output; corrected and
re-verified live end-to-end through an actual forced swap before
writing this commit, confirmed live to hold its connection open
indefinitely (unlike /logs, which closes after a fixed ~100KB).

12 tests for the corrected /api/events parsing (SSE frame buffering
across chunk boundaries, source filtering, malformed/wrong-type frames,
connection reuse, idle pruning) plus 2 for the frontend banner
rendering. Typecheck/lint/frontend-syntax clean; full suite shows no
new regressions (14 more passing than baseline, matching the new
tests; same pre-existing Windows-environment failures).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
Replaces the size-scaled expected-time estimate + matching auto-timeout
with a generic hardware/model-size disclaimer and a user-driven Cancel
button, per explicit request. Real load time depends on hardware this
feature has no way to know (VRAM, storage speed, GPU contention), so
the old estimate/timeout was a guess dressed up as a fact — worse, one
that could kill a genuinely slow load partway through on slower
hardware.

- _watchLlamaSwapLoading (session-ui.js): dropped maxWaitMs/deadline
  entirely — polls indefinitely until ready or cancelled, no automatic
  give-up. Message is now "Loading <model> (<size>) on <endpoint> —
  this can take a while depending on your hardware and the model
  size.", with the real llama.cpp log line still on its own second
  line. Removed _MODEL_LOAD_TIME_MATRIX/_estimateModelLoad/
  _formatRemaining (dead code once the countdown is gone) —
  _lookupModelSizeGB is kept, the GB figure still shows.
- _showCenterStatus (panels-ui.js) gains opts.onCancel: renders a real
  "Cancel" button (distinct from the error-type "×" close button,
  since Cancel has a real consequence) that calls it on click. Caller
  owns what cancelling actually means, same split as the swap-confirm
  modal's promise-resolving buttons.
- Cancelling dismisses the banner, shows an info toast (not an error —
  this was deliberate), and closes the session, mirroring what the old
  timeout used to do automatically but now on the user's own call.
- New .center-status-cancel CSS (bordered pill button, distinct from
  the plain "×" close glyph).

Test changes: removed the now-invalid timeout-auto-close/estimate
tests, added cancel-flow tests (dismiss/toast-type/session-close,
never-closes-with-no-sessionId, unbounded-polling), and real-DOM tests
for the new Cancel button (bootAppWithRealCenterStatus, evaluating
panels-ui.js instead of stubbing _showCenterStatus, since this button
is worth verifying for real rather than just through the stub every
other test in the file uses). Typecheck/lint/frontend-syntax clean;
full suite shows no new regressions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
Full documentation review pass across the branch's 30 commits.
CLAUDE.md's Custom Model Endpoint Profiles entry hadn't been touched
since the initial backend+picker cut (3 early commits) despite 27
follow-up commits adding real behavior — it described restart-in-place
as universal (now claude-only; 7 other CLIs launch one-shot) and
claimed codex's Responses-API gap as a flat protocol break (now
re-verified as a more precise tool-calling gap). Corrected both and
added a new paragraph covering everything landed since: the llama-swap
conflict check, the after-the-fact swap-displacement sweep, the
/running-cmd-based context-length fix, the context-window floor
warning, skipFirstRunPrompts, the real-time /api/events-based log
status, and the countdown-to-Cancel-button change.

docs/api-reference.md's custom-model-endpoints section was missing the
running-status route, the requiresConfirmation/requiresContextWarning
response shapes, and POST /api/quick-start's customModel field
entirely (the primary launch path for 7 of 8 supported CLIs) — added
all three. Also fixed a real markdown bug in custom-model-endpoints.md:
an inline code span (`POST <baseUrl>/v1/chat/completions`) split across
a line break, which CommonMark renders with the line ending collapsed
to a space, so it displayed as ".../v1/chat/ completions" with a
spurious space inside the path.

Verified: origin/master and upstream/master are both already an
ancestor of this branch (identical at bd286bf, no new commits since
this branch was cut) — nothing to merge, no conflicts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
DeepSeek Harness's own bundled provider module
(@deepseek-ai/dsh-llm-deepseek) builds its request URL as
`${DEEPSEEK_BASE_URL}/chat/completions` with no `/v1` insertion of its
own (its real public API, https://api.deepseek.com, expects the
caller's base URL to already carry any needed prefix), while
llama-swap/llama.cpp only ever serves the OpenAI-conventional
`/v1/chat/completions`.

Confirmed two ways:
- Installed the real @deepseek-ai/dsh package (all its actual
  published dependencies) into a scratch dir purely to read
  dsh-llm-deepseek's source: `fetch(`${connection.baseURL}/chat/
  completions`, ...)`, baseURL read straight from DEEPSEEK_BASE_URL —
  the same grep-the-real-source bar pi/grok's fixes were held to.
- Live against the test-picker's llama-swap: `POST <baseUrl>/chat/
  completions` -> 404, `POST <baseUrl>/v1/chat/completions` -> 200,
  same endpoint. dsh's own error template ("DeepSeek API error (HTTP
  ${status})") reproduces the originally-reported
  "dsh: HTTP_404: DeepSeek API error (HTTP 404)" exactly.

- New registry field `appendV1Suffix` (env kind only, deepseek's entry
  alone — claude/gemini must NOT get it, since claude was already
  confirmed working against the unmodified baseUrl). When set,
  buildCustomModelInjection runs endpoint.baseUrl through the same
  withV1Suffix() helper configDir-kind CLIs (pi/grok/codex) already
  use, instead of writing it verbatim.

Not yet re-run end-to-end through a real dsh binary — no install
available in this environment (not in PATH, and the test-picker
container doesn't bundle it) — so this is source-confirmed and
live-verified at the HTTP level, not yet promoted to "verified"
alongside claude/opencode/pi/grok/omp. Docs (custom-model-endpoints.md,
the plan doc's confidence table, the wiki page, CLAUDE.md) all updated
to reflect this precisely rather than leaving the old "root cause not
identified" claim in place.

2 new/updated tests for the /v1 suffix (including idempotency against
a baseUrl that already ends in /v1) plus a corrected mock-server
contract test. Typecheck/lint clean; full suite shows no new
regressions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
@opticon454
opticon454 marked this pull request as ready for review September 17, 2026 06:42
@opticon454

Copy link
Copy Markdown
Contributor Author

I've done a heap of manual testing on this one a including llama.cpp and model switching with llama-swap. Added smarts such as warning that you're about to unload another model that's currently in use by another terminal etc :)

@opticon454

Copy link
Copy Markdown
Contributor Author

@Ark0N it's ready to review

@opticon454

Copy link
Copy Markdown
Contributor Author

I've started planning Codeman's CLI-registry frontend follow-up (PR B2) but found conflicts with this PR branch, so the plan now waits to apply this branch first and then I'll complete that original PR

@Ark0N

Ark0N commented Sep 18, 2026

Copy link
Copy Markdown
Owner

Thanks for this, and for the amount of real-hardware testing behind it. This turns #393's backend into a usable feature: a generated Custom Endpoints section in the Run menu, full endpoint CRUD in App Settings, a no-restart launch path for the seven non-Claude harnesses, and a pile of llama-swap handling (swap conflicts, context-window floor, live backend log line) that clearly came from actually running it rather than from reading the code.

Two things I would like fixed before merge, then some smaller notes.

1. Editing an endpoint wipes the discovered context lengths and sizes (src/web/public/settings-ui.js:2656).

PUT /api/model-endpoints/:id replaces the whole record (custom-model-routes.ts:709 carries over only apiKey), and the editor's body sends models and lastDiscoveredAt but not modelContextLengths or modelSizesGB. So renaming an endpoint, or changing its default model, silently drops both. That turns off the two things this PR added: exceedsSafeContextFloor() reads endpoint.modelContextLengths?.[modelId] and returns false for an unknown length, so the context-window warning stops firing, and CLAUDE_CODE_MAX_CONTEXT_TOKENS stops being injected. The 5-minute sweep only re-learns a context length for a model llama-swap currently reports loaded, so for every other model it stays gone.

I confirmed this with a throwaway route test: discover a model, assert the store holds its context length and size, PUT the exact body saveCustomModelHostFromEditor() builds, and both fields come back undefined.

The schema comment at schemas.ts:1947 says those fields are accepted "so a client round-tripping the GET response back through PUT (edit-save) doesn't drop it", so the intent was already right. Either add them to the editor's body next to models, or (my preference, since it cannot be forgotten by the next caller) have the PUT handler merge the server-populated fields from the stored record the way applyStoredApiKey merges the key. A route test pinning "a PUT that omits them keeps them" would be worth having.

2. custom-model:swapped-out is broadcast unscoped (src/web/server.ts:2786).

The payload carries sessionId, sessionName, endpointId, previousModel and currentlyLoadedModel, and deriveSseHint (server.ts:2358) matches by event prefix. custom-model: is in none of its lists, so it falls through to undefined and canDeliver() returns true for every client. In multi-user mode that means everyone gets a toast naming another user's session and models. Adding 'custom-model:' to SESSION_PREFIXES is the whole fix: the payload already has sessionId, so the existing owner resolution and the sessionScoped: true fail-closed default work as-is, and single-user behaviour does not change.

3. setCustomModel() gets the merged env, which puts CLAUDE_CODE_EFFORT_LEVEL back (src/web/routes/session-routes.ts:3740).

qsCustomModelEnvOverrides is the caller's envOverrides plus the injected ones, and setCustomModel() merges its second argument straight into _envOverrides. The Session constructor strips CLAUDE_CODE_EFFORT_LEVEL out on purpose (session.ts:857) because as an env var it hard-locks in-session /effort, and re-merging the pre-constructor object puts it back. Passing cmApplied.envOverrides instead fixes it; the constructor has already applied the full set, and the bookkeeping only ever needs the injected keys.

4. The one-shot path hardcodes pi/grok/omp (src/web/routes/session-routes.ts:3708-3726).

The restart path does the same job generically off entry.launch.legacyConfigField (Session._withCustomModelLaunchModel, session.ts:1943). The quick-start path re-does it as three mode === '<id>' branches plus a new ALLOWED_BRANCHES entry. All three are covered today, but a CLI that declares customModelInjection.launchModel later will be offered by the picker (which reads capabilities), launch one-shot, and silently get no --model, running on its own default provider with nothing reporting it. Reusing legacyConfigField lets the allowlist entry go away too.

5. Sticky error toasts are now app-wide (src/web/public/panels-ui.js:5500).

The reasoning is right for the new custom-model messages, but the default now applies to all 133 showToast(..., 'error') call sites, and .toast-container has no cap, no max-height and no overflow, and showToast() never evicts. A repeatedly failing path (a flapping SSE reconnect, a poll loop) stacks sticky toasts off the bottom of the viewport where they cannot be read or dismissed, and on a phone that is only a few failures. I would either cap the container or scope the sticky default to the call sites that need it with an explicit { duration: 0 }.

Smaller things I will most likely just fix at merge:

  • GET /api/model-endpoints/:id/running-status (custom-model-routes.ts:760) has no admin gate, so a non-admin can tell a real endpoint id (200) from an unknown one (404) and read logLine, which is the backend process's own stdout including on-disk model paths. Your comment makes a fair case from the apply route being ungated; I want the logLine half to be a conscious decision.
  • docs/api-reference.md has been run through Prettier on top of the real additions (tables realigned, *x* to _x_, JSON re-indented), and the reflow de-indented continuation lines in unrelated bullets around the approvals, intent and voice sections. docs/ sits outside Prettier's scope on purpose. Reverting those hunks would make the doc diff reviewable.
  • The quick-start context and swap warnings return after the case directory has been created and scaffolded (session-routes.ts:3609), so cancelling leaves an empty case behind.
  • pumpLlamaSwapLogTail's finally (custom-model-routes.ts:438) deletes by key unconditionally, so an aborted pump finishing after a newer entry was created deletes that newer entry and orphans its stream. Guarding with an identity check covers it.
  • Cancelling a warning on the one-shot path throws through run<Mode>(), so a deliberate cancel shows as a red "Failed to start X" toast. The restart path gets this right with an info toast.
  • The phone overview's Run picker does not render the Custom Endpoints section, so the feature is desktop-only right now. Worth a line in the docs.

On the rest: the registry discipline in the picker, the endpoint store staying the only source of env values, the key never reaching the browser, the /props to /running context-length correction and the /logs to /api/events one are all exactly right, and the server-side test coverage is the best part of the PR. I also checked that rmSync recursive does not follow the projects symlink, so removeConfigDir() cannot touch the real ~/.claude/projects, which was the one place a mistake here would have been expensive.

Checks on my side: typecheck, lint, check:frontend-syntax, format:check and check:public-assets all clean, and the full npm test is green (392 files, 7446 tests, exit 0).

Push fixes for 1 to 4, tell me which way you want 5, and I will take another pass. A Playwright run over the picker and the settings panel before merge would close the gap you already flagged.

Four blockers from the 2026-09-18 review:

- PUT /api/model-endpoints/:id now merges modelContextLengths/
  modelSizesGB back in from the stored record instead of trusting the
  editor's body, so renaming an endpoint or changing its default model
  no longer silently drops the context-window floor check and
  CLAUDE_CODE_MAX_CONTEXT_TOKENS injection.
- custom-model:swapped-out is now session-scoped (added to
  SESSION_PREFIXES) instead of broadcasting to every connected client.
- The quick-start custom-model path now hands setCustomModel() only
  the endpoint's own injected env vars, not the full merged set,
  matching the restart-in-place path — the full set put
  CLAUDE_CODE_EFFORT_LEVEL back after the Session constructor had
  already stripped it.
- The quick-start launchModel override for pi/grok/omp is now applied
  generically via the registry's legacyConfigField, mirroring
  Session._withCustomModelLaunchModel, instead of three hardcoded
  mode === '<id>' branches a future CLI's injection recipe would miss.

Also scopes the sticky-toast default (item 5): reverted the blanket
"all error toasts are sticky" default, which had no container cap or
eviction, back to a flat 3s; the one message that needs a moment to
read (a failed custom-model apply) now passes an explicit
duration: 0 at its own call site.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ea59JhUmHBm1gRCsiYF33R
@opticon454

Copy link
Copy Markdown
Contributor Author

@Ark0N All done

@opticon454

Copy link
Copy Markdown
Contributor Author

Pushed 1f32128c addressing the four pre-merge blockers plus item 5:

  1. Edit wipes context lengths/sizesPUT /api/model-endpoints/:id now merges modelContextLengths/modelSizesGB back in from the stored record server-side (mirroring applyStoredApiKey), so the editor no longer needs to round-trip them and a rename/default-model edit can't silently drop the context-window floor check or CLAUDE_CODE_MAX_CONTEXT_TOKENS injection.
  2. Unscoped custom-model:swapped-out broadcast — added 'custom-model:' to SESSION_PREFIXES in server.ts, so it now resolves the owner from sessionId like every other session-scoped event instead of going to every connected client.
  3. CLAUDE_CODE_EFFORT_LEVEL re-merge — the quick-start path now passes setCustomModel() only the endpoint's own injected env vars (cmApplied.envOverrides) rather than the full merged set, matching what the restart-in-place route already did — the full set was putting the effort env var back right after the Session constructor stripped it.
  4. Hardcoded pi/grok/omp branches — the quick-start launch-model override is now applied generically off the registry's legacyConfigField, mirroring Session._withCustomModelLaunchModel, so a CLI whose custom-model injection lands later doesn't need a frontend-adjacent code change here too. Dropped the now-stale ALLOWED_BRANCHES entry in cli-registry-no-id-branching.test.ts.
  5. Sticky toasts — reverted the blanket "every error toast is sticky" default (no cap on .toast-container, no eviction) back to a flat 3s, and made the one message that actually needs it — a failed custom-model apply — opt in explicitly with duration: 0 at its own call site.

All directly relevant suites pass (custom-model-routes, quick-start-custom-model, session-custom-model, cli-registry-no-id-branching); typecheck and lint clean. Ready for another pass.

@Ark0N

Ark0N commented Sep 18, 2026

Copy link
Copy Markdown
Owner

Thanks for this, and for going back to real hardware rather than stopping at the tests: the /props n_ctx discrepancy, /logs carrying only the proxy log, the 36.4K Claude Code floor and llama-swap needing an actual inference request to swap are all things nobody finds by reading code. This turns #393 into a feature people can use: a Run menu section generated off the registry, the settings CRUD panel, and a one-shot launch path so seven of the eight harnesses no longer boot native and then restart.

Full npm test is green here (392 files, 7446 tests), as are typecheck, lint, check:frontend-syntax and format:check. One thing needs fixing before I merge, plus a few small ones.

The centred status banner never actually goes away. src/web/public/styles.css:8560 declares .center-status-banner { display: flex } with no [hidden] re-assert, so the dismiss() in _showCenterStatus (src/web/public/panels-ui.js:5591) sets el.hidden = true and nothing happens: an author-level display: flex beats the UA [hidden] { display: none }. The card stays laid out at the viewport centre with opacity: 0, and opacity: 0 is still hit-testable, while .center-status-text, .center-status-cancel and .center-status-close each set pointer-events: auto at z-index 10001. I reproduced it in headless chromium against your stylesheet: after dismiss() the element computes display: flex, opacity: 0, a 442x67 rect, and document.elementFromPoint at the centre of the screen returns .center-status-text. Since _runCustomModelEntryViaRestart shows and always dismisses this banner on every Claude custom-model launch (session-ui.js:893, dismissed at :934, :954, :963, :978, :984), one launch leaves an invisible 442x67 click blocker dead centre over the terminal until the page is reloaded. The fix is the one the rest of the stylesheet already uses (.home-sessions[hidden] at 15706, .offline-overlay[hidden] at 15551):

.center-status-banner[hidden] {
  display: none;
}

Please add that plus a small assertion so it stays fixed. test/custom-model-run-menu-ui.test.ts already has bootAppWithRealCenterStatus(), or a stylesheet assertion in the style of test/home-sessions.test.ts works too. CLAUDE.md records this exact trap for .home-sessions, and its Z-index layers list is worth a line for the new banner (10001) and the two modals at 10010 while you are there.

The changeset still advertises the sticky-toast default you reverted. .changeset/run-menu-custom-model-picker.md:10 says "Toasts now default to sticky with a close button", but 1f32128c put the flat 3s default back and moved the one message that needs reading to an explicit duration: 0. That sentence goes straight into CHANGELOG.md and the release notes. Same claim in CLAUDE.md:230 ("showToast() now defaults to STICKY") and in the .toast-message comment at styles.css:8529. docs/custom-model-endpoints.md:202 is fine as written.

Smaller things, happy to take them in the same push:

  • docs/api-reference.md:562 says discovery failures answer 502 OPERATION_FAILED; OPERATION_FAILED is 422 (src/types/api.ts:86), which the table at line 63 of the same file already states.
  • src/web/server.ts:2764: the 5-minute re-discovery sweep never reads customModelEndpointsEnabled, so turning the feature off still leaves Codeman polling every saved endpoint forever. Reading the setting inside the callback would settle it.
  • docs/api-reference.md picked up a full Prettier pass (table padding, *x* to _x_, JSON re-indent), and docs/** is outside npm run format's glob on purpose. It is about half that file's diff and it hides the real doc change, so reverting the formatting-only hunks would help.

One design question rather than a bug: confirmed: true currently answers both gates. Because the context-window check runs first (session-routes.ts:1226 then :1256, and :3613 then :3636 on quick-start), clicking "Launch anyway" on the context warning also skips the llama-swap "this will unload it for session X" check, so the user takes the model away from another session without being asked, which is the thing that warning exists to prevent. The new 20s displacement sweep catches it afterwards, so it is a surprise rather than a silent failure. I would rather have two flags, or the swap check running first. Your call; if you want to keep one flag, say so and I will leave it.

One more worth a sentence in the docs: CLAUDE_CONFIG_DIR relocates the whole .claude tree, not just transcripts, so a custom-model Claude session also loses the user's global settings.json, user-level skills (the codeman agent skill included), user-level agents and commands, and MCP servers from ~/.claude.json. That is probably a fine trade for "point this session at my local llama.cpp", but better stated than discovered.

Push the banner fix and the changeset/CLAUDE.md wording and I will merge. Worth a npm run test:browser pass diffed against master first as well, same as any frontend change, since the gate cannot see that suite.

Blocker: .center-status-banner never actually disappears.

- Add `.center-status-banner[hidden] { display: none; }`, same trap as
  `.home-sessions[hidden]`: the author-level `display: flex` beat the
  UA `[hidden]` rule, so `dismiss()` set `el.hidden = true` and the
  card stayed laid out at `opacity: 0` with its text/cancel/close
  children still `pointer-events: auto` -- an invisible 442x67 click
  blocker dead centre over the terminal until the page reloaded.
- Added a regression test pinning the CSS rule, and documented the
  banner (10001) and the swap-confirm/context-warning modals (10010)
  in CLAUDE.md's Z-index layers list.

Stale wording pointed at the reverted sticky-toast default:

- .changeset/run-menu-custom-model-picker.md, CLAUDE.md, and the
  `.toast-message` comment in styles.css all still said "toasts
  default to sticky" after 1f32128 put the flat 3s default back.
  Reworded all three to describe the actual behaviour: one call site
  passes an explicit `duration: 0`.

Smaller items from the same review:

- docs/api-reference.md said discovery failures answer
  `502 OPERATION_FAILED`; OPERATION_FAILED is 422 per src/types/api.ts
  and the error-code table earlier in the same file.
- The periodic re-discovery sweep (server.ts) never read
  customModelEndpointsEnabled, so turning the feature off left
  Codeman polling every saved endpoint forever. Added
  readCustomModelEndpointsEnabled() (custom-model-routes.ts, same
  shape as readPlanUsageTelemetryEnabled) and gated the interval
  callback on it.
- Reverted the formatting-only Prettier pass docs/api-reference.md
  picked up (table padding, *x* to _x_, JSON re-indent) by re-merging
  the new Custom Model Endpoints section onto the pre-PR file, so the
  diff is reviewable. No prose content was lost -- verified by diffing
  the result against the pre-revert file (formatting-only) and against
  the merge-base file (only the new section added).
- docs/custom-model-endpoints.md now states that a custom-model Claude
  session's isolated CLAUDE_CONFIG_DIR loses the user's global
  settings.json, user-level skills/agents/commands, and MCP servers
  from ~/.claude.json -- only `projects` is symlinked back.

Design question left open in the review (does `confirmed: true` need
to be two flags so "launch anyway" on the context warning doesn't also
skip the llama-swap displacement warning): keeping the single flag, as
offered. The 20s displacement sweep still catches a resulting swap
after the fact, so it's a surprise rather than a silent failure, and
splitting it is real behavioural surface I have no way to verify live
in this environment.

`npm run test:browser` could not be run in this environment (no tmux,
no downloaded Playwright browser binary) -- none of its suite's files
touch code this fix changes, but it still needs a real pass before
merge, same as any frontend change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ea59JhUmHBm1gRCsiYF33R
@opticon454

Copy link
Copy Markdown
Contributor Author

@Ark0N Pushed 9982a132 addressing this pass:

Blocker fixed — the .center-status-banner dismiss bug. Added .center-status-banner[hidden] { display: none; } (same trap as .home-sessions[hidden]), plus a regression test in test/custom-model-run-menu-ui.test.ts pinning the CSS rule via bootAppWithRealCenterStatus()'s neighbouring describe block. Also added the banner (10001) and the swap-confirm/context-warning modals (10010) to CLAUDE.md's Z-index layers list while I was there.

Stale sticky-toast wording fixed in all three places you found it: the changeset, CLAUDE.md, and the .toast-message comment in styles.css. All three now describe the actual behaviour (duration: 0 at the one call site that needs it) instead of the reverted blanket default.

Smaller items, all done:

  • docs/api-reference.md: discovery-failure status corrected to 422 OPERATION_FAILED.
  • server.ts's periodic re-discovery sweep now reads customModelEndpointsEnabled on every tick (new readCustomModelEndpointsEnabled() in custom-model-routes.ts, same shape as readPlanUsageTelemetryEnabled) and skips entirely while the feature is off.
  • Reverted the formatting-only Prettier pass on docs/api-reference.md by re-merging just the new Custom Model Endpoints section onto the pre-PR file — verified the result is formatting-only against the prior version and content-only-addition against the merge-base, so nothing else moved.
  • docs/custom-model-endpoints.md now says plainly that the isolated CLAUDE_CONFIG_DIR also drops the user's global settings.json, user-level skills/agents/commands, and ~/.claude.json MCP servers — only projects comes back.

Design question (confirmed: true answering both the context-window and llama-swap-displacement gates): keeping the single flag, as you offered. Splitting it or reordering the checks is real behavioural surface I have no way to verify against a live llama-swap server from here, and the 20s displacement sweep still catches the resulting swap after the fact — a surprise, not a silent failure. Happy to revisit if you'd rather I split it.

One gap to flag honestly: I could not run npm run test:browser in this environment — no tmux, and the Playwright chromium binary isn't downloaded here. None of that suite's files touch anything this fix changes (CSS visibility rule, a settings read, doc wording), but it still deserves a real pass before merge, same as any frontend change — I don't have write access to this repo to trigger CI myself, so that pass and the actual merge are over to you.

typecheck, lint, check:frontend-syntax and format:check are all clean; the directly relevant suites (custom-model-run-menu-ui, custom-model-routes, home-sessions) pass in full (88/88).

@opticon454

opticon454 commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

@Ark0N All done

@Ark0N

Ark0N commented Sep 18, 2026

Copy link
Copy Markdown
Owner

Thanks for this, and for the depth of the live validation behind it. This turns #393's backend into the whole clickable feature: a Run-menu section generated off the CLI registry, the settings CRUD panel, a one-shot launch path for the seven non-Claude harnesses, and the llama-swap swap, context-floor and displacement handling that makes it behave when the model is not already loaded.

Typecheck, lint, frontend-syntax, format-check and the full npm test gate (7447 tests) are all green here, and the test coverage you added is genuinely good. Two things I would like fixed before merge, both small.

1. The loading banner hides itself about 200ms after it appears, on the Claude path (src/web/public/panels-ui.js:5588)

_showCenterStatus reuses one shared DOM node, and dismiss() schedules el.hidden = true 200ms later with nothing cancelling that timer. In _runCustomModelEntryViaRestart (src/web/public/session-ui.js:977) you call switchingToast.dismiss() and then hand straight off to _watchLlamaSwapLoading, which awaits one same-origin /api/model-endpoints request (5 to 30ms locally) before opening the new banner. The old timer then fires and hides the new one, so the whole model-load window runs with no progress text, no llama.cpp log line and no reachable Cancel button. I reproduced it in JSDOM against the real _showCenterStatus: open, dismiss, reopen 20ms later, and el.hidden is true 300ms on.

Please park the pending timeout on the element and clear it at the top of _showCenterStatus:

if (el._hideTimer) { clearTimeout(el._hideTimer); el._hideTimer = null; }
...
const dismiss = () => {
  el.classList.remove('show');
  el._hideTimer = setTimeout(() => { el.hidden = true; el._hideTimer = null; }, 200);
};

A case in the existing _showCenterStatus Cancel button (real DOM, not the stub) block would pin it, since that harness already loads panels-ui.js.

2. The swap-conflict warning names other users' sessions (src/web/routes/session-routes.ts:1257 and :3637)

Both affectedSessions scans walk the whole ctx.sessions map with no ownership filter, and the ids and names come back to the caller and get rendered in the confirm dialog. Applying a custom model is ungated for any session owner by design, so in multi-user mode a non-admin pointing their own session at a shared endpoint learns other users' session names, which with autoNameSessions on are their prompts. canAccessOwned is already imported in that file:

.filter((s) => ... && canAccessOwned(getAuthUser(req), s.owner))

A foreign session still blocks the swap, it just is not named. A two-owner test in test/routes/session-custom-model.test.ts would be good alongside it.

A few smaller things I will pick up separately or that can ride along if you are touching these files anyway:

  • src/web/server.ts:1236: boot recovery calls applyCustomModelInjection without contextLength, so CLAUDE_CODE_MAX_CONTEXT_TOKENS drops out of the rebuilt _envOverrides after a restart and survives only because tmux retains the setenv. One line: pass endpoint.modelContextLengths?.[saved.modelId].
  • src/web/routes/custom-model-routes.ts:450: pumpLlamaSwapLogTail's finally can delete a newer tail created for the same endpoint after this one was aborted, leaving a connection nothing can abort. Guard it with if (llamaSwapLogTails.get(host.id) === entry).
  • Clearing a custom model removes every injected key by name, and CLAUDE_CONFIG_DIR is now one of them, so a session that had its own CLAUDE_CONFIG_DIR set through envOverrides (the per-client-account case) loses it and silently falls back to the default account. Worth at least a sentence in docs/custom-model-endpoints.md.
  • In quick-start, the requiresConfirmation and requiresContextWarning returns land after the case-scaffolding block, so cancelling either dialog on a new case name leaves the directory behind.
  • runCustomModelEntry's mode === 'claude' check is the one CLI-id branch left in the frontend. The static guard only walks TypeScript so it passes, and your reasoning for the split is sound, but a customModelInjection.launchStrategy field would retire it whenever this area is next touched.

On scope: this is a lot for one PR, and I know you flagged that yourself up front. I am not asking you to split it now, the commit history is readable and each piece is clearly downstream of making the feature work when you click it. Worth keeping in mind for the next one.

Send the two fixes and I will merge.

Blocker 1: the loading banner hides itself ~200ms after it reopens.

- _showCenterStatus reuses one shared DOM node; dismiss() scheduled
  el.hidden = true 200ms later with nothing to cancel it. On the
  Claude path, switchingToast.dismiss() is followed by one same-
  origin request (5-30ms locally) before _watchLlamaSwapLoading opens
  the new banner -- well inside that window -- so the stale timer
  fired against the shared node and hid the fresh banner, leaving the
  whole model-load wait with no progress text, no log line and no
  reachable Cancel button.
- Fixed by parking the pending timeout on the element and clearing it
  at the top of _showCenterStatus. Added a regression test that
  reproduces the exact repro (open, dismiss, reopen 20ms later,
  advance past 200ms) alongside the existing Cancel-button DOM tests;
  confirmed it fails without the fix and passes with it.

Blocker 2: the swap-conflict warning named other users' sessions.

- Both affectedSessions scans (POST .../custom-model and quick-start)
  walked the whole session map with no ownership filter, so in multi-
  user mode a non-admin pointing their own session at a shared
  endpoint learned another user's session name and id -- which with
  autoNameSessions on is that user's own prompt.
- The swap is still blocked pending confirmation regardless of
  ownership (a foreign session is just as real a disruption); only
  which ones get NAMED back to the caller is scoped, via the
  already-imported canAccessOwned. Added a two-owner test to
  test/routes/session-custom-model.test.ts covering both the
  foreign-owner (blocked, not named) and same-owner (named) cases.

Smaller ride-along fixes:

- server.ts boot recovery now passes contextLength into
  applyCustomModelInjection, so CLAUDE_CODE_MAX_CONTEXT_TOKENS is
  correctly rebuilt into _envOverrides after a restart instead of
  surviving only because tmux retains the old setenv.
- pumpLlamaSwapLogTail's finally now deletes by IDENTITY, not just by
  key, so an aborted pump finishing after a newer entry was created
  for the same endpoint can no longer delete that newer entry and
  orphan its connection.
- docs/custom-model-endpoints.md now notes that clearing a custom
  model removes injected keys by name, including CLAUDE_CONFIG_DIR --
  so a session that also had CLAUDE_CONFIG_DIR set via envOverrides
  (the per-client-account case) silently falls back to the default
  account on clear.

Left for later, as flagged in the review itself: the quick-start
case-scaffolding/cancel ordering (real behavioural reordering across
a large handler, too risky to make without a live re-test), and
retiring runCustomModelEntry's mode === 'claude' branch behind a
launchStrategy registry field (explicitly deferred by the reviewer to
"the next one").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ea59JhUmHBm1gRCsiYF33R
@opticon454

Copy link
Copy Markdown
Contributor Author

@Ark0N Pushed afb67544 with both fixes plus the ride-along items.

1. Loading banner hides itself ~200ms after reopening — fixed. Parked the pending hide timeout on the element (el._hideTimer) and clear it at the top of _showCenterStatus, per your snippet. Added a test in the existing Cancel-button describe block that reproduces your exact repro (open, dismiss, reopen 20ms later, advance past 200ms) — confirmed it fails on the old code and passes with the fix.

2. Swap-conflict warning naming other users' sessions — fixed, both call sites (session-routes.ts dedicated route and quick-start). The swap is still blocked pending confirmation regardless of ownership — a foreign session is just as real a disruption — but only sessions the caller can access get named back via canAccessOwned, same as you sketched. Added a two-owner test to test/routes/session-custom-model.test.ts covering both the foreign-owner (blocked, not named) and same-owner (named) cases.

Ride-along fixes, all done:

  • server.ts boot recovery now passes contextLength into applyCustomModelInjection, so CLAUDE_CODE_MAX_CONTEXT_TOKENS is correctly rebuilt after a restart.
  • pumpLlamaSwapLogTail's finally now deletes by identity, not just by key, per your guard suggestion.
  • docs/custom-model-endpoints.md now notes that clearing a custom model removes CLAUDE_CONFIG_DIR by name too, so a session using it via envOverrides for a per-client account silently falls back to the default account on clear.

Left for later, as you yourself flagged: the quick-start case-scaffolding/cancel ordering (real reordering across a large handler — didn't want to touch that without a live re-test I can't do here) and the mode === 'claude' branch retirement behind a launchStrategy field (you said "worth keeping in mind for the next one").

typecheck, lint, and format:check are clean. Directly relevant suites: 111/112 passing (session-custom-model, quick-start-custom-model, custom-model-routes, custom-model-run-menu-ui) — the one failure is the same pre-existing Windows chmod-0600 assertion from earlier rounds (this dev box is Windows; not something introduced here).

As before I don't have write access to this repo to run CI or merge — that and a test:browser pass are over to you.

@Ark0N

Ark0N commented Sep 19, 2026

Copy link
Copy Markdown
Owner

Thanks for this. It turns #393's backend into something people can actually use: a Run menu section generated off the CLI registry, full endpoint CRUD in settings, and a lot of hardening you clearly found by running it against a real llama-swap box rather than by reading the code.

Checks are green here: typecheck, lint, format:check, check:frontend-syntax and check:public-assets all clean, and the full npm test gate passes (397 files, 7527 tests). The coverage on the picker and the polling loop is better than most frontend PRs get.

A few things before this goes in.

1. The privilegedEnvKeys widening changes behaviour outside this feature (src/config/cli-registry/stock.ts:244-245).

Adding CLAUDE_CODE_MAX_CONTEXT_TOKENS and CLAUDE_CONFIG_DIR there does not do what the comment says. privilegedEnvKeys has exactly one consumer, ownerClampedEnvKeys() in src/session-env-clamp.ts:65, and that feeds the generic envOverrides clamp on POST /api/sessions, POST /api/quick-start and reboot-restore. No custom-model route reads it, and the injected values are merged after the clamp anyway, so this feature does not strictly need the entry.

What it does do, in multi-user mode for a non-granted owner: CLAUDE_CONFIG_DIR can no longer be set through envOverrides (that is the per-client-account feature from #255), and a persisted one is now dropped on reboot-restore, which silently switches that session to the default Claude account. It also makes src/session-env-clamp.ts:10-16 false, since that fileoverview states that claude's privileged keys are the five ANTHROPIC_* names and that a persisted record therefore cannot carry a clamped key.

I have decided this one rather than leaving it to you: keep both keys. types.ts:485 genuinely does say every traffic-redirecting var this feature introduces must be listed, and I would rather the rule stay literally true than carve an exception into it for the two keys that happen not to need it today. So the entry stays and the consequences get written down instead. What I need from you:

  • correct the src/session-env-clamp.ts:10-16 fileoverview, which now states the opposite of what the code does
  • correct the rationale comment at stock.ts:244-245, since "listed here only so the custom-model route clamps them" is not what the field does
  • add a line to CLAUDE.md's CLAUDE_CONFIG_DIR gotcha saying it is admin-only in multi-user mode, and that a persisted one is dropped on reboot-restore for a non-granted owner
  • add a claude clamp test next to the deepseek and omp ones, so the new behaviour is pinned rather than incidental

2. running-status returns more than it needs (src/web/routes/custom-model-routes.ts:805).

Leaving the route un-gated is fine, but it passes cmd through: the literal llama-server ... launch line, which carries model paths and can carry --api-key. cmd exists only so parseCtxFromCmd() can read it server-side during discovery; _watchLlamaSwapLoading reads only model and state. Please map to { model, state } in the route and keep cmd internal.

3. Two comments point at code that no longer exists.

src/custom-model-hosts.ts:71 still sends modelSizesGB readers to estimateModelLoad() in session-ui.js, which went away with the countdown. And src/web/routes/custom-model-routes.ts:802 plus src/web/server.ts:2815 still say "/logs tail" for what is now an /api/events tail, which reads oddly right next to the docs explaining why /logs was the wrong endpoint.

4. CLAUDE.md counts (CLAUDE.md:384 and :388).

sse-events.ts went from 158 to 159 and its own header was updated, but CLAUDE.md's SSE section still says "158 = 158". Same at line 388: running-status makes it custom-model (6) and ~234 handlers.

Smaller things, which I will most likely just apply at merge rather than send back:

  • runCustomModelEntry branches on mode === 'claude' (session-ui.js:774). The no-id-branching guard only walks .ts files, so it cannot see frontend JS. Reasonable for now given what runClaude() carries, but a launchStyle field on the capability would keep the rule true and make the eventual claude conversion a data change.
  • isCliAvailable() treats an unknown id as available, and __codemanCliAvailable only carries the stock ids, so a custom entry in clis.json that declares customModelInjection gets a menu row whose click falls through run()'s dispatch into runClaude() and launches a plain native Claude session with no error (session-ui.js:565).
  • Both apply routes gate the context warning and the swap-conflict warning on the same confirmed flag, and the context check runs first, so "Launch anyway" on a too-small context also skips the "this will unload it for someone else's session" ask (session-routes.ts:1161 and :3554).
  • Cancelling a swap or context dialog on the one-shot path comes back through _reportSessionLaunchError as a red failure; the restart path shows an info toast for the same action.
  • pumpLlamaSwapLogTail's buffer only shrinks at a \n\n boundary (custom-model-routes.ts:435), so an endpoint that streams without frame boundaries grows it without bound. A cap would close that.

Do 1 as described above and fix 2, and I will take 3, 4 and the smaller list at merge. Nice work tracking the /props versus /running context discrepancy and the DeepSeek /v1 root cause down to the SDK source instead of guessing at them.

One scheduling note so you are not guessing: the next release is going out with the three small terminal and Run fixes that are ready now, and this is not in it. That is purely because it is a feature of this size arriving while a patch release was already being cut, not a verdict on the PR. Once 1 and 2 are pushed it goes in on its own, and it is the headline when it does.

…aster (Ark0N)

Merged upstream/master (22 commits: reboot-restore recovery feature,
terminal keycode229 recovery work, install.sh/CLI-catalog generator
changes, CHANGELOG/version bump to 1.30.0) into this branch. No
conflicts; git auto-merged every overlapping file (CLAUDE.md,
docs/api-reference.md, app.js, index.html, styles.css, routes/index.ts,
session-routes.ts, schemas.ts, server.ts).

Two required fixes from the latest review:

1. privilegedEnvKeys widening (stock.ts) changes behaviour outside this
   feature. The reviewer decided to keep both CLAUDE_CODE_MAX_CONTEXT_TOKENS
   and CLAUDE_CONFIG_DIR listed (types.ts's rule that every traffic-
   redirecting var this feature introduces must appear there stays
   literally true), and asked for the real consequences documented
   instead of hidden:
   - Corrected session-env-clamp.ts's fileoverview, which stated the
     opposite of what the code now does (reboot-restore's clamp call
     used to be able to strip nothing for claude; it now strips a
     persisted CLAUDE_CONFIG_DIR for a non-granted owner).
   - Corrected the rationale comments in stock.ts: privilegedEnvKeys
     has exactly one consumer (ownerClampedEnvKeys, feeding the
     generic envOverrides clamp on create/quick-start/reboot-restore),
     not the custom-model routes.
   - Added a CLAUDE.md line to the CLAUDE_CONFIG_DIR gotcha covering
     the admin-only-in-multi-user-mode and reboot-restore-strips-it
     consequences.
   - Added a "Claude multi-user clamp" test next to the existing
     DeepSeek/OMP ones, pinning the new stripping behaviour.

2. GET .../running-status (custom-model-routes.ts) no longer passes
   the raw llama-swap `cmd` field (the literal launch line, which can
   carry model paths and --api-key) to the browser -- the frontend
   only ever reads model/state, cmd exists solely for server-side
   parseCtxFromCmd() during discovery. Added a test asserting the
   response never contains cmd or a planted secret.

Also regenerated config/clis.stock.json and install.sh's catalogue
block (npm run generate:cli-catalog) to clear drift introduced by the
upstream merge, since it was failing the sync check.

Left to the reviewer, as they said they'd take at merge: the two
"comments pointing at removed code" cleanups, the two stale CLAUDE.md
counts, and the small items list (mode==='claude' frontend branch,
isCliAvailable() unknown-id gap, shared confirmed flag ordering,
one-shot cancel toast severity, pumpLlamaSwapLogTail buffer cap).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ea59JhUmHBm1gRCsiYF33R
@opticon454

Copy link
Copy Markdown
Contributor Author

@Ark0N Two things in this push: caught the branch up with master (22 commits, incl. the reboot-restore feature and the 1.30.0 release), and addressed this review round.

Merge: no conflicts — git auto-merged every overlapping file (CLAUDE.md, docs/api-reference.md, app.js, index.html, styles.css, routes/index.ts, session-routes.ts, schemas.ts, server.ts). Also had to regenerate config/clis.stock.json/install.sh's catalogue block, since the merge left them out of sync with the generator and cli-catalog-sync.test.ts was failing.

1. privilegedEnvKeys widening — done as you specified (keep both keys, document the consequences):

  • Corrected session-env-clamp.ts's fileoverview, which said the opposite of what the code now does.
  • Corrected the rationale comments in stock.ts:229-233 and :240-245 — the field's one consumer is ownerClampedEnvKeys() feeding the generic envOverrides clamp, not a custom-model route.
  • Added the admin-only / reboot-restore-strips-it line to CLAUDE.md's CLAUDE_CONFIG_DIR gotcha.
  • Added a "Claude multi-user clamp" test next to the DeepSeek/OMP ones in test/routes/session-custom-model.test.ts, pinning the strip for a non-granted owner and the no-op in single-user mode.

2. running-status no longer returns cmd. Now maps to {model, state} in the route; cmd stays internal to parseCtxFromCmd(). Added a test that plants a fake --api-key in a mocked cmd and asserts it never reaches the response body.

Left for you at merge, as you said: the two "points at removed code" comment fixes, the two stale CLAUDE.md counts, and the smaller list (the mode === 'claude' frontend branch, isCliAvailable()'s unknown-id gap, the shared confirmed flag ordering, the one-shot cancel toast severity, and the pumpLlamaSwapLogTail buffer cap).

typecheck, lint, format:check, check:frontend-syntax and check:lockfile are all clean. Directly relevant suites pass in full aside from the one pre-existing Windows chmod-0600 assertion flagged in earlier rounds (this dev box is Windows).

@Ark0N

Ark0N commented Sep 19, 2026

Copy link
Copy Markdown
Owner

Both items from the last round are in, and the review of this head found one more that I am very glad it caught before this shipped.

The API-key trust seed never matched a real key. seedApiKeyTrustFile() wrote the key verbatim into customApiKeyResponses.approved, but Claude Code stores and compares only the last 20 characters (key.trim().slice(-20), applied on both the write and the lookup). For any real key the seed missed, and claude stopped at the interactive "Detected a custom API key in your environment" prompt, whose default is "No (recommended)". So the launch either hangs or silently refuses the key this feature just injected and falls through to an OAuth login the isolated config dir does not have.

The reason it got through both of us is worth writing down: DEFAULT_API_KEY is local-dummy-key, 15 characters, so on a keyless llama.cpp or llama-swap endpoint slice(-20) returns the whole string and the seed matches by accident. Every test used a short key too (my-key, k), so the suite could not see it either. Fixed via truncateApiKeyForTrustFile(), with a test using a 57-character key that also asserts the full credential never reaches that second file on disk. Cloud endpoints (OpenRouter, Azure) would have hit this immediately.

I also did the two-flag split I deferred last round. The context-floor warning and the swap-conflict warning shared one confirmed, and since the context check runs first, a user clicking "launch anyway" past a too-small context silently consented to evicting another session's model. They are questions about different people, so both routes now read confirmedContext and confirmedSwap independently. The legacy confirmed still means both, because it shipped in the API-only cut and an existing caller must keep working. The frontend answers each question with its own flag and accumulates them, on all three call sites.

Also taken at merge: the swap dialog no longer renders " are currently using ..." when multi-user scoping leaves the affected-session list empty (the swap is blocked regardless of ownership, only the names are scoped); the llama-swap log tails are closed in WebServer.stop() rather than only by the idle sweep whose interval that same teardown disposes; pumpLlamaSwapLogTail's unparsed remainder is capped, since it only shrank at a \n\n boundary; running-status's cmd is gone from docs/api-reference.md; the SSE and route counts are corrected; and the two comments pointing at removed code are fixed.

On the modelId validation: two places claimed the routes check it against the endpoint's discovered models and neither does. I dropped the claim rather than adding the check, because discovery can be up to five minutes stale and a 400 there would refuse a launch that actually works. CLAUDE.md now says the absence is deliberate.

And the changeset. It had grown to 1602 words of development log as the PR grew. That text becomes CHANGELOG.md and the release body verbatim, so I rewrote it as one user-facing account at about a fifth the length. Nothing was dropped that a user would care about; what went were the round-by-round process notes, which belong on this thread and are all still here.

Left as follow-ups, none of them merge conditions: architecture-invariants.md not carrying the privilegedEnvKeys widening you documented in CLAUDE.md, scripts/test-local-llm-harnesses.ts ignoring the four new env-kind injection fields, deleteCustomModelHost() swallowing a 403, the phone Run picker not mirroring custom entries, and runCustomModelEntry() branching on mode === 'claude' for the two launch paths (a launchStyle capability would keep the no-id-branching rule literally true and make the eventual claude conversion a data change).

A correction to what I told you last round: I said this was not going into the next release and would headline its own. It is going into 1.31.0 after all. You turned the round fast enough that holding it back would have been arbitrary, and the release was still open. A final review of the whole release tree is running now; assuming it comes back clean this ships today.

Thanks for the depth on this one, and for the two rounds of turning it around the same day.

@Ark0N
Ark0N merged commit 1a99b58 into Ark0N:master Sep 19, 2026
2 checks passed
@github-actions github-actions Bot mentioned this pull request Sep 19, 2026
@opticon454
opticon454 deleted the feature/run-menu-custom-model-picker branch September 19, 2026 11:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants